auth fixes, search connected

This commit is contained in:
Untone 2025-05-22 04:34:30 +03:00
parent 32bc1276e0
commit ab39b534fe
23 changed files with 610 additions and 359 deletions

View File

@ -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

View File

@ -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)

54
auth/handler.py Normal file
View File

@ -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

View File

@ -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)

7
cache/cache.py vendored
View File

@ -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

347
main.py
View File

@ -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=["*"],
)

View File

@ -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",
]

View File

@ -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}")

View File

@ -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

View File

@ -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):

View File

@ -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)}"}

View File

@ -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)

View File

@ -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:

View File

@ -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)

View File

@ -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):

View File

@ -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 = {}

View File

@ -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"}

View File

@ -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

View File

@ -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

View File

@ -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": "Настройки отправки электронной почты"
},

View File

@ -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")

View File

@ -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")