From ab39b534fe2a27ecf6cf8dcf34df49283e1921fb Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 22 May 2025 04:34:30 +0300 Subject: [PATCH] auth fixes, search connected --- CHANGELOG.md | 22 ++ auth/decorators.py | 3 - auth/handler.py | 54 ++++ auth/middleware.py | 79 +++++- cache/cache.py | 7 +- main.py | 347 +++++++++++------------ pyrightconfig.json => pylanceconfig.json | 0 resolvers/__init__.py | 4 +- resolvers/auth.py | 57 +++- resolvers/author.py | 88 ++---- resolvers/collab.py | 23 +- resolvers/draft.py | 87 +++++- resolvers/editor.py | 46 ++- resolvers/follower.py | 24 +- resolvers/rating.py | 1 - resolvers/reaction.py | 11 +- resolvers/reader.py | 39 +++ resolvers/topic.py | 4 +- schema/query.graphql | 1 - services/auth.py | 9 +- services/env.py | 8 +- services/search.py | 52 +++- settings.py | 3 + 23 files changed, 610 insertions(+), 359 deletions(-) create mode 100644 auth/handler.py rename pyrightconfig.json => pylanceconfig.json (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index eda89c90..c27e3225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [Unreleased] + +### Добавлено +- Статистика пользователя (shouts, followers, authors, comments) в ответе метода `getSession` +- Интеграция с функцией `get_with_stat` для единого подхода к получению статистики + +### Исправлено +- Ошибка в функции `unpublish_shout`: + - Исправлена проверка наличия связанного черновика: `if shout.draft is not None` + - Правильное получение черновика через его ID с загрузкой связей +- Добавлена ​​реализация функции `unpublish_draft`: + - Корректная работа с идентификаторами draft и связанного shout + - Снятие shout с публикации по ID черновика + - Обновление кэша после снятия с публикации +- Ошибка в функции `get_shouts_with_links`: + - Добавлена корректная обработка полей `updated_by` и `deleted_by`, которые могут быть null + - Исправлена ошибка "Cannot return null for non-nullable field Author.id" + - Добавлена проверка существования авторов для полей `updated_by` и `deleted_by` +- Ошибка в функции `get_reactions_with_stat`: + - Добавлен вызов метода `distinct()` перед применением `limit` и `offset` для предотвращения дублирования результатов + - Улучшена документация функции с описанием обработки результатов запроса + - Оптимизирована сортировка и группировка результатов для корректной работы с joined eager loads #### [0.4.23] - 2025-05-25 diff --git a/auth/decorators.py b/auth/decorators.py index dd3079c9..768a314c 100644 --- a/auth/decorators.py +++ b/auth/decorators.py @@ -376,16 +376,13 @@ def login_accepted(func): try: author = session.query(Author).filter(Author.id == auth.author_id).one() info.context["author"] = author.dict() - info.context["user_id"] = author.id logger.debug(f"[login_accepted] Пользователь авторизован: {author.id}") except exc.NoResultFound: logger.warning(f"[login_accepted] Пользователь с ID {auth.author_id} не найден в базе данных") info.context["author"] = None - info.context["user_id"] = None else: # Если пользователь не авторизован, устанавливаем пустые значения info.context["author"] = None - info.context["user_id"] = None logger.debug("[login_accepted] Пользователь не авторизован") return await func(parent, info, *args, **kwargs) diff --git a/auth/handler.py b/auth/handler.py new file mode 100644 index 00000000..813655d1 --- /dev/null +++ b/auth/handler.py @@ -0,0 +1,54 @@ +from ariadne.asgi.handlers import GraphQLHTTPHandler +from starlette.requests import Request +from starlette.responses import Response, JSONResponse +from auth.middleware import auth_middleware +from utils.logger import root_logger as logger + +class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler): + """ + Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации. + + Расширяет стандартный GraphQLHTTPHandler для: + 1. Создания расширенного контекста запроса с авторизационными данными + 2. Корректной обработки ответов с cookie и headers + 3. Интеграции с AuthMiddleware + """ + + async def get_context_for_request(self, request: Request, data: dict) -> dict: + """ + Расширяем контекст для GraphQL запросов. + + Добавляет к стандартному контексту: + - Объект response для установки cookie + - Интеграцию с AuthMiddleware + - Расширения для управления авторизацией + + Args: + request: Starlette Request объект + data: данные запроса + + Returns: + dict: контекст с дополнительными данными для авторизации и cookie + """ + # Получаем стандартный контекст от базового класса + context = await super().get_context_for_request(request, data) + + # Создаем объект ответа для установки cookie + response = JSONResponse({}) + context["response"] = response + + # Интегрируем с AuthMiddleware + auth_middleware.set_context(context) + context["extensions"] = auth_middleware + + # Добавляем данные авторизации только если они доступны + # Без проверки hasattr, так как это вызывает ошибку до обработки AuthenticationMiddleware + if hasattr(request, "auth") and request.auth: + # Используем request.auth вместо request.user, так как user еще не доступен + context["auth"] = request.auth + # Безопасно логируем информацию о типе объекта auth + logger.debug(f"[graphql] Добавлены данные авторизации в контекст: {type(request.auth).__name__}") + + logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса") + + return context diff --git a/auth/middleware.py b/auth/middleware.py index 239a54f8..e67187ce 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -1,11 +1,13 @@ """ Middleware для обработки авторизации в GraphQL запросах """ - +from typing import Any, Dict +from starlette.requests import Request +from starlette.responses import JSONResponse, Response from starlette.datastructures import Headers from starlette.types import ASGIApp, Scope, Receive, Send from utils.logger import root_logger as logger -from settings import SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME +from settings import SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_SAMESITE, SESSION_COOKIE_SECURE, SESSION_TOKEN_HEADER, SESSION_COOKIE_NAME class AuthMiddleware: @@ -197,3 +199,76 @@ class AuthMiddleware: except Exception as e: logger.error(f"[AuthMiddleware] Ошибка в GraphQL resolve: {str(e)}") raise + + async def process_result(self, request: Request, result: Any) -> Response: + """ + Обрабатывает результат GraphQL запроса, поддерживая установку cookie + + Args: + request: Starlette Request объект + result: результат GraphQL запроса (dict или Response) + + Returns: + Response: HTTP-ответ с результатом и cookie (если необходимо) + """ + + # Проверяем, является ли result уже объектом Response + if isinstance(result, Response): + response = result + # Пытаемся получить данные из response для проверки логина/логаута + result_data = {} + if isinstance(result, JSONResponse): + try: + import json + result_data = json.loads(result.body.decode('utf-8')) + except Exception as e: + logger.error(f"[process_result] Не удалось извлечь данные из JSONResponse: {str(e)}") + else: + response = JSONResponse(result) + result_data = result + + # Проверяем, был ли токен в запросе или ответе + if request.method == "POST": + try: + data = await request.json() + op_name = data.get("operationName", "").lower() + + # Если это операция логина или обновления токена, и в ответе есть токен + if op_name in ["login", "refreshtoken"]: + token = None + # Пытаемся извлечь токен из данных ответа + if result_data and isinstance(result_data, dict): + data_obj = result_data.get("data", {}) + if isinstance(data_obj, dict) and op_name in data_obj: + op_result = data_obj.get(op_name, {}) + if isinstance(op_result, dict) and "token" in op_result: + token = op_result.get("token") + + if token: + # Устанавливаем cookie с токеном + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=SESSION_COOKIE_HTTPONLY, + secure=SESSION_COOKIE_SECURE, + samesite=SESSION_COOKIE_SAMESITE, + max_age=SESSION_COOKIE_MAX_AGE, + ) + logger.debug(f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}") + + # Если это операция logout, удаляем cookie + elif op_name == "logout": + response.delete_cookie( + key=SESSION_COOKIE_NAME, + secure=SESSION_COOKIE_SECURE, + httponly=SESSION_COOKIE_HTTPONLY, + samesite=SESSION_COOKIE_SAMESITE + ) + logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") + except Exception as e: + logger.error(f"[process_result] Ошибка при обработке POST запроса: {str(e)}") + + return response + +# Создаем единый экземпляр AuthMiddleware для использования с GraphQL +auth_middleware = AuthMiddleware(lambda scope, receive, send: None) \ No newline at end of file diff --git a/cache/cache.py b/cache/cache.py index 0e94fff4..1bb4564c 100644 --- a/cache/cache.py +++ b/cache/cache.py @@ -301,7 +301,7 @@ async def get_cached_follower_topics(author_id: int): # Get author by user ID from cache -async def get_cached_author_by_user_id(user_id: str, get_with_stat): +async def get_cached_author_by_id(user_id: str, get_with_stat): """ Retrieve author information by user_id, checking the cache first, then the database. @@ -312,7 +312,7 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat): dict: Dictionary with author data or None if not found. """ # Attempt to find author ID by user_id in Redis cache - author_id = await redis.execute("GET", f"author:user:{user_id.strip()}") + author_id = await redis.execute("GET", f"author:user:{author_id}") if author_id: # If ID is found, get full author data by ID author_data = await redis.execute("GET", f"author:id:{author_id}") @@ -320,14 +320,13 @@ async def get_cached_author_by_user_id(user_id: str, get_with_stat): return orjson.loads(author_data) # If data is not found in cache, query the database - author_query = select(Author).where(Author.id == user_id) + author_query = select(Author).where(Author.id == author_id) authors = get_with_stat(author_query) if authors: # Cache the retrieved author data author = authors[0] author_dict = author.dict() await asyncio.gather( - redis.execute("SET", f"author:user:{user_id.strip()}", str(author.id)), redis.execute("SET", f"author:id:{author.id}", orjson.dumps(author_dict)), ) return author_dict diff --git a/main.py b/main.py index 05f33c16..cb71791e 100644 --- a/main.py +++ b/main.py @@ -5,35 +5,32 @@ from os.path import exists, join from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL -from ariadne.asgi.handlers import GraphQLHTTPHandler + +from auth.handler import EnhancedGraphQLHTTPHandler +from auth.internal import InternalAuthentication +from auth.middleware import auth_middleware, AuthMiddleware from starlette.applications import Starlette from starlette.middleware.cors import CORSMiddleware from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware import Middleware from starlette.requests import Request -from starlette.responses import FileResponse, JSONResponse, Response +from starlette.responses import JSONResponse, Response from starlette.routing import Route, Mount from starlette.staticfiles import StaticFiles -from starlette.types import ASGIApp from cache.precache import precache_data from cache.revalidator import revalidation_manager from services.exception import ExceptionHandlerMiddleware from services.redis import redis from services.schema import create_all_tables, resolvers -from services.search import search_service, initialize_search_index - +from services.search import check_search_service, initialize_search_index_background, search_service +from services.viewed import ViewedStorage from utils.logger import root_logger as logger -from auth.internal import InternalAuthentication -from auth.middleware import AuthMiddleware -from settings import ( - SESSION_COOKIE_NAME, - SESSION_COOKIE_HTTPONLY, - SESSION_COOKIE_SECURE, - SESSION_COOKIE_SAMESITE, - SESSION_COOKIE_MAX_AGE, - SESSION_TOKEN_HEADER, -) +from settings import DEV_SERVER_PID_FILE_NAME + +DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false" +DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов +INDEX_HTML = join(os.path.dirname(__file__), "index.html") # Импортируем резолверы import_module("resolvers") @@ -41,118 +38,6 @@ import_module("resolvers") # Создаем схему GraphQL schema = make_executable_schema(load_schema_from_path("schema/"), resolvers) -# Пути к клиентским файлам -DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов -INDEX_HTML = join(os.path.dirname(__file__), "index.html") - - -async def check_search_service(): - """Check if search service is available and log result""" - info = await search_service.info() - if info.get("status") in ["error", "unavailable"]: - print(f"[WARNING] Search service unavailable: {info.get('message', 'unknown reason')}") - else: - print(f"[INFO] Search service is available: {info}") - - -async def index_handler(request: Request): - """ - Раздача основного HTML файла - """ - return FileResponse(INDEX_HTML) - - -# Создаем единый экземпляр AuthMiddleware для использования с GraphQL -auth_middleware = AuthMiddleware(lambda scope, receive, send: None) - - -class EnhancedGraphQLHTTPHandler(GraphQLHTTPHandler): - """ - Улучшенный GraphQL HTTP обработчик с поддержкой cookie и авторизации - """ - - async def get_context_for_request(self, request: Request, data: dict) -> dict: - """ - Расширяем контекст для GraphQL запросов - """ - # Получаем стандартный контекст от базового класса - context = await super().get_context_for_request(request, data) - - # Создаем объект ответа для установки cookie - response = JSONResponse({}) - context["response"] = response - - # Интегрируем с AuthMiddleware - auth_middleware.set_context(context) - context["extensions"] = auth_middleware - - logger.debug(f"[graphql] Подготовлен расширенный контекст для запроса") - - return context - - async def process_result(self, request: Request, result: dict) -> Response: - """ - Обрабатывает результат GraphQL запроса, поддерживая установку cookie - """ - # Получаем контекст запроса - context = getattr(request, "context", {}) - - # Получаем заранее созданный response из контекста - response = context.get("response") - - if not response or not isinstance(response, Response): - # Если response не найден или не является объектом Response, создаем новый - response = await super().process_result(request, result) - else: - # Обновляем тело ответа данными из результата GraphQL - response.body = self.encode_json(result) - response.headers["content-type"] = "application/json" - response.headers["content-length"] = str(len(response.body)) - - logger.debug(f"[graphql] Подготовлен ответ с типом {type(response).__name__}") - - return response - - -# Функция запуска сервера -async def start(): - """Запуск сервера и инициализация данных""" - # Инициализируем соединение с Redis - await redis.connect() - logger.info("Установлено соединение с Redis") - - # Создаем все таблицы в БД - create_all_tables() - - # Запускаем предварительное кеширование данных - asyncio.create_task(precache_data()) - - # Запускаем задачу ревалидации кеша - asyncio.create_task(revalidation_manager.start()) - - # Выводим сообщение о запуске сервера и доступности API - logger.info("Сервер запущен и готов принимать запросы") - logger.info("GraphQL API доступно по адресу: /graphql") - logger.info("Админ-панель доступна по адресу: http://127.0.0.1:8000/") - - -# Функция остановки сервера -async def shutdown(): - """Остановка сервера и освобождение ресурсов""" - logger.info("Остановка сервера") - - # Закрываем соединение с Redis - await redis.disconnect() - - # Останавливаем поисковый сервис - search_service.close() - - # Удаляем PID-файл, если он существует - from settings import DEV_SERVER_PID_FILE_NAME - if exists(DEV_SERVER_PID_FILE_NAME): - os.unlink(DEV_SERVER_PID_FILE_NAME) - - # Создаем middleware с правильным порядком middleware = [ # Начинаем с обработки ошибок @@ -172,9 +57,9 @@ middleware = [ allow_headers=["*"], allow_credentials=True, ), - # После CORS идёт обработка авторизации + # Сначала AuthMiddleware (для обработки токенов) Middleware(AuthMiddleware), - # И затем аутентификация + # Затем AuthenticationMiddleware (для создания request.user на основе токена) Middleware(AuthenticationMiddleware, backend=InternalAuthentication()), ] @@ -182,81 +67,169 @@ middleware = [ # Создаем экземпляр GraphQL с улучшенным обработчиком graphql_app = GraphQL( schema, - debug=True, + debug=DEVMODE, http_handler=EnhancedGraphQLHTTPHandler() ) # Оборачиваем GraphQL-обработчик для лучшей обработки ошибок async def graphql_handler(request: Request): + """ + Обработчик GraphQL запросов с поддержкой middleware и обработкой ошибок. + + Выполняет: + 1. Проверку метода запроса (GET, POST, OPTIONS) + 2. Обработку GraphQL запроса через ariadne + 3. Применение middleware для корректной обработки cookie и авторизации + 4. Обработку исключений и формирование ответа + + Args: + request: Starlette Request объект + + Returns: + Response: объект ответа (обычно JSONResponse) + """ if request.method not in ["GET", "POST", "OPTIONS"]: return JSONResponse({"error": "Method Not Allowed by main.py"}, status_code=405) + + # Проверяем, что все необходимые middleware корректно отработали + if not hasattr(request, "scope") or "auth" not in request.scope: + logger.warning("[graphql] AuthMiddleware не обработал запрос перед GraphQL обработчиком") try: - # Обрабатываем CORS для OPTIONS запросов - if request.method == "OPTIONS": - response = JSONResponse({}) - response.headers["Access-Control-Allow-Origin"] = "*" - response.headers["Access-Control-Allow-Methods"] = "POST, GET, OPTIONS" - response.headers["Access-Control-Allow-Headers"] = "*" - response.headers["Access-Control-Allow-Credentials"] = "true" - response.headers["Access-Control-Max-Age"] = "86400" # 24 hours - return response - + # Обрабатываем запрос через GraphQL приложение result = await graphql_app.handle_request(request) - # Если результат не является Response, преобразуем его в JSONResponse - if not isinstance(result, Response): - response = JSONResponse(result) - - # Проверяем, был ли токен в запросе или ответе - if request.method == "POST" and isinstance(result, dict): - data = await request.json() - op_name = data.get("operationName", "").lower() - - # Если это операция логина или обновления токена, и в ответе есть токен - if (op_name in ["login", "refreshtoken"]) and result.get("data", {}).get(op_name, {}).get("token"): - token = result["data"][op_name]["token"] - # Устанавливаем cookie с токеном - response.set_cookie( - key=SESSION_COOKIE_NAME, - value=token, - httponly=SESSION_COOKIE_HTTPONLY, - secure=SESSION_COOKIE_SECURE, - samesite=SESSION_COOKIE_SAMESITE, - max_age=SESSION_COOKIE_MAX_AGE, - ) - logger.debug(f"[graphql_handler] Установлена cookie {SESSION_COOKIE_NAME} для операции {op_name}") - - # Если это операция logout, удаляем cookie - elif op_name == "logout": - response.delete_cookie( - key=SESSION_COOKIE_NAME, - secure=SESSION_COOKIE_SECURE, - httponly=SESSION_COOKIE_HTTPONLY, - samesite=SESSION_COOKIE_SAMESITE - ) - logger.debug(f"[graphql_handler] Удалена cookie {SESSION_COOKIE_NAME} для операции {op_name}") - - return response - - return result + # Применяем middleware для установки cookie + # Используем метод process_result из auth_middleware для корректной обработки + # cookie на основе результатов операций login/logout + response = await auth_middleware.process_result(request, result) + return response except asyncio.CancelledError: return JSONResponse({"error": "Request cancelled"}, status_code=499) except Exception as e: logger.error(f"GraphQL error: {str(e)}") + # Логируем более подробную информацию для отладки + import traceback + logger.debug(f"GraphQL error traceback: {traceback.format_exc()}") return JSONResponse({"error": str(e)}, status_code=500) - -# Добавляем маршруты, порядок имеет значение -routes = [ - Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]), - Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)), -] -# Создаем приложение Starlette с маршрутами и middleware + +async def shutdown(): + """Остановка сервера и освобождение ресурсов""" + logger.info("Остановка сервера") + + # Закрываем соединение с Redis + await redis.disconnect() + + # Останавливаем поисковый сервис + search_service.close() + + # Удаляем PID-файл, если он существует + from settings import DEV_SERVER_PID_FILE_NAME + if exists(DEV_SERVER_PID_FILE_NAME): + os.unlink(DEV_SERVER_PID_FILE_NAME) + + +async def dev_start(): + """ + Инициализация сервера в DEV режиме. + + Функция: + 1. Проверяет наличие DEV режима + 2. Создает PID-файл для отслеживания процесса + 3. Логирует информацию о старте сервера + + Используется только при запуске сервера с флагом "dev". + """ + try: + pid_path = DEV_SERVER_PID_FILE_NAME + # Если PID-файл уже существует, проверяем, не запущен ли уже сервер с этим PID + if exists(pid_path): + try: + with open(pid_path, "r", encoding="utf-8") as f: + old_pid = int(f.read().strip()) + # Проверяем, существует ли процесс с таким PID + import signal + try: + os.kill(old_pid, 0) # Сигнал 0 только проверяет существование процесса + print(f"[warning] DEV server already running with PID {old_pid}") + except OSError: + print(f"[info] Stale PID file found, previous process {old_pid} not running") + except (ValueError, FileNotFoundError): + print(f"[warning] Invalid PID file found, recreating") + + # Создаем или перезаписываем PID-файл + with open(pid_path, "w", encoding="utf-8") as f: + f.write(str(os.getpid())) + print(f"[main] process started in DEV mode with PID {os.getpid()}") + except Exception as e: + logger.error(f"[main] Error during server startup: {str(e)}") + # Не прерываем запуск сервера из-за ошибки в этой функции + print(f"[warning] Error during DEV mode initialization: {str(e)}") + + +async def lifespan(_app): + """ + Функция жизненного цикла приложения. + + Обеспечивает: + 1. Инициализацию всех необходимых сервисов и компонентов + 2. Предзагрузку кеша данных + 3. Подключение к Redis и поисковому сервису + 4. Корректное завершение работы при остановке сервера + + Args: + _app: экземпляр Starlette приложения + + Yields: + None: генератор для управления жизненным циклом + """ + try: + print("[lifespan] Starting application initialization") + create_all_tables() + await asyncio.gather( + redis.connect(), + precache_data(), + ViewedStorage.init(), + check_search_service(), + revalidation_manager.start(), + ) + if DEVMODE: + await dev_start() + print("[lifespan] Basic initialization complete") + + # Add a delay before starting the intensive search indexing + print("[lifespan] Waiting for system stabilization before search indexing...") + await asyncio.sleep(10) # 10-second delay to let the system stabilize + + # Start search indexing as a background task with lower priority + asyncio.create_task(initialize_search_index_background()) + + yield + finally: + print("[lifespan] Shutting down application services") + tasks = [redis.disconnect(), ViewedStorage.stop(), revalidation_manager.stop()] + await asyncio.gather(*tasks, return_exceptions=True) + print("[lifespan] Shutdown complete") + +# Обновляем маршрут в Starlette app = Starlette( - routes=routes, - middleware=middleware, - on_startup=[start], - on_shutdown=[shutdown], + routes=[ + Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]), + Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)) + ], + lifespan=lifespan, + middleware=middleware, # Явно указываем список middleware + debug=True, ) + +if DEVMODE: + # Для DEV режима регистрируем дополнительный CORS middleware только для localhost + app.add_middleware( + CORSMiddleware, + allow_origins=["https://localhost:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) diff --git a/pyrightconfig.json b/pylanceconfig.json similarity index 100% rename from pyrightconfig.json rename to pylanceconfig.json diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 55a6ce7b..c63cf01a 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -5,7 +5,6 @@ from resolvers.author import ( # search_authors, get_author_follows, get_author_follows_authors, get_author_follows_topics, - get_author_id, get_authors_all, load_authors_by, load_authors_search, @@ -18,6 +17,7 @@ from resolvers.draft import ( load_drafts, publish_draft, update_draft, + unpublish_draft, ) from resolvers.editor import ( unpublish_shout, @@ -91,7 +91,6 @@ __all__ = [ # author "get_author", - "get_author_id", "get_author_followers", "get_author_follows", "get_author_follows_topics", @@ -161,7 +160,6 @@ __all__ = [ "update_draft", "delete_draft", "publish_draft", - "publish_shout", "unpublish_shout", "unpublish_draft", ] diff --git a/resolvers/auth.py b/resolvers/auth.py index ef6cb3b2..6eaf2327 100644 --- a/resolvers/auth.py +++ b/resolvers/auth.py @@ -42,11 +42,11 @@ async def get_current_user(_, info): info: Контекст GraphQL запроса Returns: - dict: Объект с токеном и данными автора + dict: Объект с токеном и данными автора с добавленной статистикой """ # Получаем данные авторизации из контекста запроса - user_id = info.context.get("user_id") - if not user_id: + author_id = info.context.get("author", {}).get("id") + if not author_id: logger.error("[getSession] Пользователь не авторизован") from graphql.error import GraphQLError raise GraphQLError("Требуется авторизация") @@ -60,19 +60,50 @@ async def get_current_user(_, info): # Получаем данные автора author = info.context.get("author") - # Если автор не найден в контексте, пробуем получить из БД + # Если автор не найден в контексте, пробуем получить из БД с добавлением статистики if not author: logger.debug(f"[getSession] Автор не найден в контексте для пользователя {user_id}, получаем из БД") - with local_session() as session: - try: - db_author = session.query(Author).filter(Author.id == user_id).one() - db_author.last_seen = int(time.time()) - session.commit() - author = db_author - except Exception as e: - logger.error(f"[getSession] Ошибка при получении автора из БД: {e}") + + try: + # Используем функцию get_with_stat для получения автора со статистикой + from sqlalchemy import select + from resolvers.stat import get_with_stat + + q = select(Author).where(Author.id == user_id) + authors_with_stat = get_with_stat(q) + + if authors_with_stat and len(authors_with_stat) > 0: + author = authors_with_stat[0] + + # Обновляем last_seen отдельной транзакцией + with local_session() as session: + author_db = session.query(Author).filter(Author.id == user_id).first() + if author_db: + author_db.last_seen = int(time.time()) + session.commit() + else: + logger.error(f"[getSession] Автор с ID {user_id} не найден в БД") from graphql.error import GraphQLError - raise GraphQLError("Ошибка при получении данных пользователя") + raise GraphQLError("Пользователь не найден") + + except Exception as e: + logger.error(f"[getSession] Ошибка при получении автора из БД: {e}", exc_info=True) + from graphql.error import GraphQLError + raise GraphQLError("Ошибка при получении данных пользователя") + else: + # Если автор уже есть в контексте, добавляем статистику + try: + from sqlalchemy import select + from resolvers.stat import get_with_stat + + q = select(Author).where(Author.id == user_id) + authors_with_stat = get_with_stat(q) + + if authors_with_stat and len(authors_with_stat) > 0: + # Обновляем только статистику + author.stat = authors_with_stat[0].stat + except Exception as e: + logger.warning(f"[getSession] Не удалось добавить статистику к автору: {e}") # Возвращаем данные сессии logger.info(f"[getSession] Успешно получена сессия для пользователя {user_id}") diff --git a/resolvers/author.py b/resolvers/author.py index ceb05d0f..f55b47b4 100644 --- a/resolvers/author.py +++ b/resolvers/author.py @@ -8,7 +8,6 @@ from cache.cache import ( cache_author, cached_query, get_cached_author, - get_cached_author_by_user_id, get_cached_author_followers, get_cached_follower_authors, get_cached_follower_topics, @@ -205,25 +204,24 @@ async def invalidate_authors_cache(author_id=None): @mutation.field("update_author") @login_required async def update_author(_, info, profile): - user_id = info.context.get("user_id") + author_id = info.context.get("author", {}).get("id") is_admin = info.context.get("is_admin", False) - - if not user_id: + if not author_id: return {"error": "unauthorized", "author": None} try: with local_session() as session: - author = session.query(Author).where(Author.id == user_id).first() + author = session.query(Author).where(Author.id == author_id).first() if author: Author.update(author, profile) session.add(author) session.commit() - author_query = select(Author).where(Author.id == user_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): # Кэшируем полную версию для админов - author_dict = author_with_stat.dict(is_admin=True) + author_dict = author_with_stat.dict(access=is_admin) asyncio.create_task(cache_author(author_dict)) # Возвращаем обычную полную версию, т.к. это владелец @@ -244,16 +242,16 @@ async def get_authors_all(_, info): list: Список всех авторов """ # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - authors = await get_all_authors(current_user_id, False) + viewer_id = info.context.get("author", {}).get("id") + is_admin = info.context.get("is_admin", False) + authors = await get_all_authors(viewer_id, is_admin) return authors @query.field("get_author") async def get_author(_, info, slug="", author_id=0): # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False + is_admin = info.context.get("is_admin", False) author_dict = None try: @@ -272,7 +270,7 @@ async def get_author(_, info, slug="", author_id=0): if hasattr(temp_author, key): setattr(temp_author, key, value) # Получаем отфильтрованную версию - author_dict = temp_author.dict(current_user_id, is_admin) + author_dict = temp_author.dict(access=is_admin) # Добавляем статистику, которая могла быть в кэшированной версии if "stat" in cached_author: author_dict["stat"] = cached_author["stat"] @@ -285,11 +283,11 @@ async def get_author(_, info, slug="", author_id=0): author_with_stat = result[0] if isinstance(author_with_stat, Author): # Кэшируем полные данные для админов - original_dict = author_with_stat.dict(is_admin=True) + original_dict = author_with_stat.dict(access=True) asyncio.create_task(cache_author(original_dict)) # Возвращаем отфильтрованную версию - author_dict = author_with_stat.dict(current_user_id, is_admin) + author_dict = author_with_stat.dict(access=is_admin) # Добавляем статистику if hasattr(author_with_stat, "stat"): author_dict["stat"] = author_with_stat.stat @@ -302,42 +300,6 @@ async def get_author(_, info, slug="", author_id=0): return author_dict -@query.field("get_author_id") -async def get_author_id(_, info, user: str): - # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False - - user_id = user.strip() - logger.info(f"getting author id for {user_id}") - author = None - try: - cached_author = await get_cached_author_by_user_id(user_id, get_with_stat) - if cached_author: - # Создаем объект автора для использования метода dict - temp_author = Author() - for key, value in cached_author.items(): - if hasattr(temp_author, key): - setattr(temp_author, key, value) - # Возвращаем отфильтрованную версию - return temp_author.dict(current_user_id, is_admin) - - author_query = select(Author).filter(Author.id == user_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(is_admin=True) - asyncio.create_task(cache_author(original_dict)) - - # Возвращаем отфильтрованную версию - return author_with_stat.dict(current_user_id, is_admin) - except Exception as exc: - logger.error(f"Error getting author: {exc}") - return None - - @query.field("load_authors_by") async def load_authors_by(_, info, by, limit, offset): """ @@ -352,11 +314,11 @@ async def load_authors_by(_, info, by, limit, offset): list: Список авторов с учетом критерия """ # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False + viewer_id = info.context.get("author", {}).get("id") + is_admin = info.context.get("is_admin", False) # Используем оптимизированную функцию для получения авторов - return await get_authors_with_stats(limit, offset, by, current_user_id, is_admin) + return await get_authors_with_stats(limit, offset, by, viewer_id, is_admin) @query.field("load_authors_search") @@ -423,8 +385,8 @@ def get_author_id_from(slug="", user=None, author_id=None): @query.field("get_author_follows") async def get_author_follows(_, info, slug="", user=None, author_id=0): # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False + viewer_id = info.context.get("author", {}).get("id") + is_admin = info.context.get("is_admin", False) logger.debug(f"getting follows for @{slug}") author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) @@ -447,7 +409,7 @@ async def get_author_follows(_, info, slug="", user=None, author_id=0): # temp_author - это объект Author, который мы хотим сериализовать # current_user_id - ID текущего авторизованного пользователя (может быть None) # is_admin - булево значение, является ли текущий пользователь админом - has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id)) followed_authors.append(temp_author.dict(access=has_access)) # TODO: Get followed communities too @@ -472,13 +434,13 @@ async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None @query.field("get_author_follows_authors") async def get_author_follows_authors(_, info, slug="", user=None, author_id=None): # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False + viewer_id = info.context.get("author", {}).get("id") + is_admin = info.context.get("is_admin", False) logger.debug(f"getting followed authors for @{slug}") - author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) if not author_id: return [] + # Получаем данные из кэша followed_authors_raw = await get_cached_follower_authors(author_id) @@ -495,7 +457,7 @@ async def get_author_follows_authors(_, info, slug="", user=None, author_id=None # temp_author - это объект Author, который мы хотим сериализовать # current_user_id - ID текущего авторизованного пользователя (может быть None) # is_admin - булево значение, является ли текущий пользователь админом - has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id)) followed_authors.append(temp_author.dict(access=has_access)) return followed_authors @@ -517,8 +479,8 @@ def create_author(user_id: str, slug: str, name: str = ""): @query.field("get_author_followers") async def get_author_followers(_, info, slug: str = "", user: str = "", author_id: int = 0): # Получаем ID текущего пользователя и флаг админа из контекста - current_user_id = info.context.get("user_id") if hasattr(info, "context") else None - is_admin = info.context.get("is_admin", False) if hasattr(info, "context") else False + viewer_id = info.context.get("author", {}).get("id") + is_admin = info.context.get("is_admin", False) logger.debug(f"getting followers for author @{slug} or ID:{author_id}") author_id = get_author_id_from(slug=slug, user=user, author_id=author_id) @@ -540,7 +502,7 @@ async def get_author_followers(_, info, slug: str = "", user: str = "", author_i # temp_author - это объект Author, который мы хотим сериализовать # current_user_id - ID текущего авторизованного пользователя (может быть None) # is_admin - булево значение, является ли текущий пользователь админом - has_access = is_admin or (current_user_id is not None and str(current_user_id) == str(temp_author.id)) + has_access = is_admin or (viewer_id is not None and str(viewer_id) == str(temp_author.id)) followers.append(temp_author.dict(access=has_access)) return followers diff --git a/resolvers/collab.py b/resolvers/collab.py index 39cf03f0..bb750b59 100644 --- a/resolvers/collab.py +++ b/resolvers/collab.py @@ -9,7 +9,6 @@ from services.schema import mutation @mutation.field("accept_invite") @login_required async def accept_invite(_, info, invite_id: int): - info.context["user_id"] author_dict = info.context["author"] author_id = author_dict.get("id") if author_id: @@ -41,7 +40,6 @@ async def accept_invite(_, info, invite_id: int): @mutation.field("reject_invite") @login_required async def reject_invite(_, info, invite_id: int): - info.context["user_id"] author_dict = info.context["author"] author_id = author_dict.get("id") @@ -64,14 +62,17 @@ async def reject_invite(_, info, invite_id: int): @mutation.field("create_invite") @login_required async def create_invite(_, info, slug: str = "", author_id: int = 0): - user_id = info.context["user_id"] author_dict = info.context["author"] - author_id = author_dict.get("id") + viewer_id = author_dict.get("id") + roles = info.context.get("roles", []) + is_admin = info.context.get("is_admin", False) + if not viewer_id and not is_admin and "admin" not in roles and "editor" not in roles: + return {"error": "Access denied"} 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 == user_id).first() + inviter = session.query(Author).filter(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 = ( @@ -89,7 +90,7 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0): # Create a new invite new_invite = Invite( - inviter_id=user_id, + inviter_id=viewer_id, author_id=author_id, shout_id=shout.id, status=InviteStatus.PENDING.value, @@ -107,9 +108,13 @@ async def create_invite(_, info, slug: str = "", author_id: int = 0): @mutation.field("remove_author") @login_required async def remove_author(_, info, slug: str = "", author_id: int = 0): - user_id = info.context["user_id"] + 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 == user_id).first() + author = session.query(Author).filter(Author.id == author_id).first() if author: shout = session.query(Shout).filter(Shout.slug == slug).first() # NOTE: owner should be first in a list @@ -123,8 +128,6 @@ async def remove_author(_, info, slug: str = "", author_id: int = 0): @mutation.field("remove_invite") @login_required async def remove_invite(_, info, invite_id: int): - info.context["user_id"] - author_dict = info.context["author"] author_id = author_dict.get("id") if isinstance(author_id, int): diff --git a/resolvers/draft.py b/resolvers/draft.py index 2b4216c6..8ee52bd3 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -78,12 +78,11 @@ async def load_drafts(_, info): Returns: dict: Список черновиков или сообщение об ошибке """ - user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") - if not user_id or not author_id: - return {"error": "User ID and author ID are required"} + if not author_id: + return {"error": "Author ID is required"} try: with local_session() as session: @@ -152,11 +151,10 @@ async def create_draft(_, info, draft_input): ... assert result['draft'].title == 'Test' ... return result """ - user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") - if not user_id or not author_id: + if not author_id: return {"error": "Author ID is required"} # Проверяем обязательные поля @@ -227,11 +225,10 @@ async def update_draft(_, info, draft_id: int, draft_input): Returns: dict: Обновленный черновик или сообщение об ошибке """ - user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") - if not user_id or not author_id: + if not author_id: return {"error": "Author ID are required"} try: @@ -389,11 +386,10 @@ async def publish_draft(_, info, draft_id: int): Returns: dict: Результат публикации с shout или сообщением об ошибке """ - user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") - if not user_id or not author_id: + if not author_id: return {"error": "Author ID is required"} try: @@ -469,7 +465,7 @@ async def publish_draft(_, info, draft_id: int): await notify_shout(shout.id) # Обновляем поисковый индекс - search_service.index_shout(shout) + search_service.perform_index(shout) logger.info(f"Successfully published shout #{shout.id} from draft #{draft_id}") logger.debug(f"Shout data: {shout.dict()}") @@ -479,3 +475,74 @@ async def publish_draft(_, info, draft_id: int): except Exception as e: logger.error(f"Failed to publish draft {draft_id}: {e}", exc_info=True) return {"error": f"Failed to publish draft: {str(e)}"} + + +@mutation.field("unpublish_draft") +@login_required +async def unpublish_draft(_, info, draft_id: int): + """ + Снимает с публикации черновик, обновляя связанный Shout. + + Args: + draft_id (int): ID черновика, публикацию которого нужно снять + + Returns: + dict: Результат операции с информацией о черновике или сообщением об ошибке + """ + author_dict = info.context.get("author", {}) + author_id = author_dict.get("id") + + if author_id: + return {"error": "Author ID is required"} + + try: + with local_session() as session: + # Загружаем черновик со связанной публикацией + draft = ( + session.query(Draft) + .options( + joinedload(Draft.publication), + joinedload(Draft.authors), + joinedload(Draft.topics) + ) + .filter(Draft.id == draft_id) + .first() + ) + + if not draft: + return {"error": "Draft not found"} + + # Проверяем, есть ли публикация + if not draft.publication: + return {"error": "This draft is not published yet"} + + shout = draft.publication + + # Снимаем с публикации + shout.published_at = None + shout.updated_at = int(time.time()) + shout.updated_by = author_id + + session.commit() + + # Инвалидируем кэш + cache_keys = [f"shouts:{shout.id}"] + await invalidate_shouts_cache(cache_keys) + await invalidate_shout_related_cache(shout, author_id) + + # Формируем результат + draft_dict = draft.dict() + # Добавляем информацию о публикации + draft_dict["publication"] = { + "id": shout.id, + "slug": shout.slug, + "published_at": None + } + + logger.info(f"Successfully unpublished shout #{shout.id} for draft #{draft_id}") + + return {"draft": draft_dict} + + except Exception as e: + logger.error(f"Failed to unpublish draft {draft_id}: {e}", exc_info=True) + return {"error": f"Failed to unpublish draft: {str(e)}"} diff --git a/resolvers/editor.py b/resolvers/editor.py index 6aa3f009..47b99dc9 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -86,12 +86,11 @@ async def get_my_shout(_, info, shout_id: int): ... assert result['shout'].id == 1 ... return result """ - user_id = info.context.get("user_id", "") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") roles = info.context.get("roles", []) shout = None - if not user_id or not author_id: + if not author_id: return {"error": "unauthorized", "shout": None} with local_session() as session: shout = ( @@ -136,7 +135,6 @@ async def get_my_shout(_, info, shout_id: int): @query.field("get_shouts_drafts") @login_required async def get_shouts_drafts(_, info): - # user_id = info.context.get("user_id") author_dict = info.context.get("author") if not author_dict: return {"error": "author profile was not found"} @@ -160,16 +158,15 @@ async def get_shouts_drafts(_, info): # @login_required async def create_shout(_, info, inp): logger.info(f"Starting create_shout with input: {inp}") - user_id = info.context.get("user_id") author_dict = info.context.get("author") - logger.debug(f"Context user_id: {user_id}, author: {author_dict}") + logger.debug(f"Context author: {author_dict}") if not author_dict: logger.error("Author profile not found in context") return {"error": "author profile was not found"} author_id = author_dict.get("id") - if user_id and author_id: + if author_id: try: with local_session() as session: author_id = int(author_id) @@ -268,7 +265,7 @@ async def create_shout(_, info, inp): logger.error(f"Unexpected error in create_shout: {e}", exc_info=True) return {"error": f"Unexpected error: {str(e)}"} - error_msg = "cant create shout" if user_id else "unauthorized" + error_msg = "cant create shout" if author_id else "unauthorized" logger.error(f"Create shout failed: {error_msg}") return {"error": error_msg} @@ -394,26 +391,19 @@ def patch_topics(session, shout, topics_input): # @mutation.field("update_shout") # @login_required async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): - logger.info(f"Starting update_shout with id={shout_id}, publish={publish}") - logger.debug(f"Full shout_input: {shout_input}") # DraftInput - - user_id = info.context.get("user_id") - roles = info.context.get("roles", []) - author_dict = info.context.get("author") - if not author_dict: - logger.error("Author profile not found") - return {"error": "author profile was not found"} - - author_id = author_dict.get("id") - shout_input = shout_input or {} - current_time = int(time.time()) - shout_id = shout_id or shout_input.get("id", shout_id) - slug = shout_input.get("slug") - - if not user_id: + author_id = info.context.get("author").get("id") + if not author_id: logger.error("Unauthorized update attempt") return {"error": "unauthorized"} + logger.info(f"Starting update_shout with id={shout_id}, publish={publish}") + logger.debug(f"Full shout_input: {shout_input}") # DraftInput + roles = info.context.get("roles", []) + current_time = int(time.time()) + shout_input = shout_input or {} + shout_id = shout_id or shout_input.get("id", shout_id) + slug = shout_input.get("slug") + try: with local_session() as session: if author_id: @@ -620,13 +610,12 @@ async def update_shout(_, info, shout_id: int, shout_input=None, publish=False): # @mutation.field("delete_shout") # @login_required async def delete_shout(_, info, shout_id: int): - user_id = info.context.get("user_id") - roles = info.context.get("roles", []) author_dict = info.context.get("author") if not author_dict: return {"error": "author profile was not found"} author_id = author_dict.get("id") - if user_id and author_id: + roles = info.context.get("roles", []) + if author_id: author_id = int(author_id) with local_session() as session: shout = session.query(Shout).filter(Shout.id == shout_id).first() @@ -643,7 +632,6 @@ async def delete_shout(_, info, shout_id: int): for author in shout.authors: await cache_by_id(Author, author.id, cache_author) info.context["author"] = author.dict() - info.context["user_id"] = author.id unfollow(None, info, "shout", shout.slug) for topic in shout.topics: @@ -746,7 +734,7 @@ async def unpublish_shout(_, info, shout_id: int): return {"error": "Shout not found"} # Если у публикации есть связанный черновик, загружаем его с relationships - if shout.draft: + if shout.draft is not None: # Отдельно загружаем черновик с его связями draft = ( session.query(Draft) diff --git a/resolvers/follower.py b/resolvers/follower.py index e1bb3a83..16e8c4cf 100644 --- a/resolvers/follower.py +++ b/resolvers/follower.py @@ -27,12 +27,14 @@ from utils.logger import root_logger as logger @login_required async def follow(_, info, what, slug="", entity_id=0): logger.debug("Начало выполнения функции 'follow'") - user_id = info.context.get("user_id") + viewer_id = info.context.get("author", {}).get("id") + if not viewer_id: + return {"error": "Access denied"} follower_dict = info.context.get("author") logger.debug(f"follower: {follower_dict}") - if not user_id or not follower_dict: - return GraphQLError("unauthorized") + if not viewer_id or not follower_dict: + return GraphQLError("Access denied") follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") @@ -107,7 +109,6 @@ async def follow(_, info, what, slug="", entity_id=0): # Если это авторы, получаем безопасную версию if what == "AUTHOR": # Получаем ID текущего пользователя и фильтруем данные - current_user_id = user_id follows_filtered = [] for author_data in existing_follows: @@ -117,7 +118,7 @@ async def follow(_, info, what, slug="", entity_id=0): if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - follows_filtered.append(temp_author.dict(current_user_id, False)) + follows_filtered.append(temp_author.dict(viewer_id, False)) if not existing_sub: # Создаем объект автора для entity_dict @@ -126,7 +127,7 @@ async def follow(_, info, what, slug="", entity_id=0): if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - follows = [*follows_filtered, temp_author.dict(current_user_id, False)] + follows = [*follows_filtered, temp_author.dict(viewer_id, False)] else: follows = follows_filtered else: @@ -149,13 +150,15 @@ async def follow(_, info, what, slug="", entity_id=0): @login_required async def unfollow(_, info, what, slug="", entity_id=0): logger.debug("Начало выполнения функции 'unfollow'") - user_id = info.context.get("user_id") + viewer_id = info.context.get("author", {}).get("id") + if not viewer_id: + return GraphQLError("Access denied") follower_dict = info.context.get("author") logger.debug(f"follower: {follower_dict}") - if not user_id or not follower_dict: + if not viewer_id or not follower_dict: logger.warning("Неавторизованный доступ при попытке отписаться") - return {"error": "unauthorized"} + return GraphQLError("Unauthorized") follower_id = follower_dict.get("id") logger.debug(f"follower_id: {follower_id}") @@ -219,7 +222,6 @@ async def unfollow(_, info, what, slug="", entity_id=0): # Если это авторы, получаем безопасную версию if what == "AUTHOR": # Получаем ID текущего пользователя и фильтруем данные - current_user_id = user_id follows_filtered = [] for author_data in existing_follows: @@ -232,7 +234,7 @@ async def unfollow(_, info, what, slug="", entity_id=0): if hasattr(temp_author, key): setattr(temp_author, key, value) # Добавляем отфильтрованную версию - follows_filtered.append(temp_author.dict(current_user_id, False)) + follows_filtered.append(temp_author.dict(viewer_id, False)) follows = follows_filtered else: diff --git a/resolvers/rating.py b/resolvers/rating.py index cf848904..397e8eac 100644 --- a/resolvers/rating.py +++ b/resolvers/rating.py @@ -96,7 +96,6 @@ async def get_my_rates_shouts(_, info, shouts): @mutation.field("rate_author") @login_required async def rate_author(_, info, rated_slug, value): - info.context["user_id"] rater_id = info.context.get("author", {}).get("id") with local_session() as session: rater_id = int(rater_id) diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 642aa8ed..e2c4db56 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -383,11 +383,11 @@ async def update_reaction(_, info, reaction): :param reaction: Dictionary with reaction data. :return: Dictionary with updated reaction data or error. """ - user_id = info.context.get("user_id") + author_id = info.context.get("author", {}).get("id") roles = info.context.get("roles") rid = reaction.get("id") - if not rid or not user_id or not roles: + if not rid or not author_id or not roles: return {"error": "Invalid input data"} del reaction["id"] @@ -437,16 +437,15 @@ async def delete_reaction(_, info, reaction_id: int): :param reaction_id: Reaction ID to delete. :return: Dictionary with deleted reaction data or error. """ - user_id = info.context.get("user_id") author_id = info.context.get("author", {}).get("id") roles = info.context.get("roles", []) - if not user_id: + if not author_id: return {"error": "Unauthorized"} with local_session() as session: try: - author = session.query(Author).filter(Author.id == user_id).one() + author = session.query(Author).filter(Author.id == author_id).one() r = session.query(Reaction).filter(Reaction.id == reaction_id).one() if r.created_by != author_id and "editor" not in roles: @@ -463,7 +462,7 @@ async def delete_reaction(_, info, reaction_id: int): session.commit() # TODO: add more reaction types here else: - logger.debug(f"{user_id} user removing his #{reaction_id} reaction") + logger.debug(f"{author_id} user removing his #{reaction_id} reaction") session.delete(r) session.commit() if check_to_unfeature(session, r): diff --git a/resolvers/reader.py b/resolvers/reader.py index 579e3a63..ac3fda6d 100644 --- a/resolvers/reader.py +++ b/resolvers/reader.py @@ -217,6 +217,7 @@ def get_shouts_with_links(info, q, limit=20, offset=0): shout_id = int(f"{shout.id}") shout_dict = shout.dict() + # Обработка поля 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() @@ -226,6 +227,44 @@ def get_shouts_with_links(info, q, limit=20, offset=0): "slug": a.slug, "pic": a.pic, } + + # Обработка поля updated_by + 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() + if updated_author: + shout_dict["updated_by"] = { + "id": updated_author.id, + "name": updated_author.name, + "slug": updated_author.slug, + "pic": updated_author.pic, + } + else: + # Если автор не найден, устанавливаем поле в null + shout_dict["updated_by"] = None + else: + # Если updated_by не указан, устанавливаем поле в null + shout_dict["updated_by"] = None + + # Обработка поля deleted_by + 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() + if deleted_author: + shout_dict["deleted_by"] = { + "id": deleted_author.id, + "name": deleted_author.name, + "slug": deleted_author.slug, + "pic": deleted_author.pic, + } + else: + # Если автор не найден, устанавливаем поле в null + shout_dict["deleted_by"] = None + else: + # Если deleted_by не указан, устанавливаем поле в null + shout_dict["deleted_by"] = None if has_field(info, "stat"): stat = {} diff --git a/resolvers/topic.py b/resolvers/topic.py index fdd5fcce..22705ff5 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -315,12 +315,12 @@ async def update_topic(_, _info, topic_input): @mutation.field("delete_topic") @login_required async def delete_topic(_, info, slug: str): - user_id = info.context["user_id"] + viewer_id = info.context.get("author", {}).get("id") with local_session() as session: t: Topic = session.query(Topic).filter(Topic.slug == slug).first() if not t: return {"error": "invalid topic slug"} - author = session.query(Author).filter(Author.id == user_id).first() + author = session.query(Author).filter(Author.id == viewer_id).first() if author: if t.created_by != author.id: return {"error": "access denied"} diff --git a/schema/query.graphql b/schema/query.graphql index 31c29f77..255e8f35 100644 --- a/schema/query.graphql +++ b/schema/query.graphql @@ -1,7 +1,6 @@ type Query { # author get_author(slug: String, author_id: Int): Author - get_author_id(user: String!): Author get_authors_all: [Author] load_authors_by(by: AuthorsBy!, limit: Int, offset: Int): [Author] load_authors_search(text: String!, limit: Int, offset: Int): [Author!] # Search for authors by name or bio diff --git a/services/auth.py b/services/auth.py index fa11cbef..e523f85b 100644 --- a/services/auth.py +++ b/services/auth.py @@ -3,7 +3,7 @@ from typing import Tuple from starlette.requests import Request -from cache.cache import get_cached_author_by_user_id +from cache.cache import get_cached_author_by_id from resolvers.stat import get_with_stat from utils.logger import root_logger as logger from auth.internal import verify_internal_auth @@ -147,13 +147,12 @@ def login_required(f): raise GraphQLError("У вас нет необходимых прав для доступа") logger.info(f"Авторизован пользователь {user_id} с ролями: {user_roles}") - info.context["user_id"] = user_id.strip() info.context["roles"] = user_roles # Проверяем права администратора info.context["is_admin"] = is_admin - author = await get_cached_author_by_user_id(user_id, get_with_stat) + author = await get_cached_author_by_id(user_id, get_with_stat) if not author: logger.error(f"Профиль автора не найден для пользователя {user_id}") info.context["author"] = author @@ -177,14 +176,13 @@ def login_accepted(f): if user_id and user_roles: logger.info(f"login_accepted: Пользователь авторизован: {user_id} с ролями {user_roles}") - info.context["user_id"] = user_id.strip() info.context["roles"] = user_roles # Проверяем права администратора info.context["is_admin"] = is_admin # Пробуем получить профиль автора - author = await get_cached_author_by_user_id(user_id, get_with_stat) + author = await get_cached_author_by_id(user_id, get_with_stat) if author: logger.debug(f"login_accepted: Найден профиль автора: {author}") # Используем флаг is_admin из контекста или передаем права владельца для собственных данных @@ -196,7 +194,6 @@ def login_accepted(f): ) else: logger.debug("login_accepted: Пользователь не авторизован. Очищаем контекст.") - info.context["user_id"] = None info.context["roles"] = None info.context["author"] = None info.context["is_admin"] = False diff --git a/services/env.py b/services/env.py index 52c0f712..c3a57218 100644 --- a/services/env.py +++ b/services/env.py @@ -63,16 +63,16 @@ class EnvManager: }, "APP": { "pattern": r"^(APP|PORT|HOST|DEBUG|DOMAIN|ENVIRONMENT|ENV|FRONTEND)_", - "name": "Приложение", - "description": "Основные настройки приложения" + "name": "Общие настройки", + "description": "Общие настройки приложения" }, "LOGGING": { "pattern": r"^(LOG|LOGGING|SENTRY|GLITCH|GLITCHTIP)_", - "name": "Логирование", + "name": "Мониторинг", "description": "Настройки логирования и мониторинга" }, "EMAIL": { - "pattern": r"^(MAIL|EMAIL|SMTP)_", + "pattern": r"^(MAIL|EMAIL|SMTP|IMAP|POP3|POST)_", "name": "Электронная почта", "description": "Настройки отправки электронной почты" }, diff --git a/services/search.py b/services/search.py index f83e4050..f2dd3b46 100644 --- a/services/search.py +++ b/services/search.py @@ -5,13 +5,12 @@ import os import httpx import time import random -from collections import defaultdict -from datetime import datetime, timedelta +from settings import TXTAI_SERVICE_URL # Set up proper logging logger = logging.getLogger("search") logger.setLevel(logging.INFO) # Change to INFO to see more details -# Disable noise HTTP client logging +# Disable noise HTTP cltouchient logging logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.WARNING) @@ -19,7 +18,7 @@ logging.getLogger("httpcore").setLevel(logging.WARNING) SEARCH_ENABLED = bool( os.environ.get("SEARCH_ENABLED", "true").lower() in ["true", "1", "yes"] ) -TXTAI_SERVICE_URL = os.environ.get("TXTAI_SERVICE_URL", "none") + MAX_BATCH_SIZE = int(os.environ.get("SEARCH_MAX_BATCH_SIZE", "25")) # Search cache configuration @@ -948,3 +947,48 @@ async def initialize_search_index(shouts_data): categories.add(getattr(matching_shouts[0], "category", "unknown")) except Exception as e: pass + + +async def check_search_service(): + info = await search_service.info() + if info.get("status") in ["error", "unavailable"]: + print(f"[WARNING] Search service unavailable: {info.get('message', 'unknown reason')}") + else: + print(f"[INFO] Search service is available: {info}") + + +# Initialize search index in the background +async def initialize_search_index_background(): + """ + Запускает индексацию поиска в фоновом режиме с низким приоритетом. + + Эта функция: + 1. Загружает все shouts из базы данных + 2. Индексирует их в поисковом сервисе + 3. Выполняется асинхронно, не блокируя основной поток + 4. Обрабатывает возможные ошибки, не прерывая работу приложения + + Индексация запускается с задержкой после инициализации сервера, + чтобы не создавать дополнительную нагрузку при запуске. + """ + try: + print("[search] Starting background search indexing process") + from services.db import fetch_all_shouts + + # Get total count first (optional) + all_shouts = await fetch_all_shouts() + total_count = len(all_shouts) if all_shouts else 0 + print(f"[search] Fetched {total_count} shouts for background indexing") + + if not all_shouts: + print("[search] No shouts found for indexing, skipping search index initialization") + return + + # Start the indexing process with the fetched shouts + print("[search] Beginning background search index initialization...") + await initialize_search_index(all_shouts) + print("[search] Background search index initialization complete") + except Exception as e: + print(f"[search] Error in background search indexing: {str(e)}") + # Логируем детали ошибки для диагностики + logger.exception("[search] Detailed search indexing error") diff --git a/settings.py b/settings.py index 32f7a54e..67a9643c 100644 --- a/settings.py +++ b/settings.py @@ -70,3 +70,6 @@ SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "") MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io") + + +TXTAI_SERVICE_URL = os.environ.get("TXTAI_SERVICE_URL", "none") \ No newline at end of file