This commit is contained in:
@@ -37,6 +37,7 @@ from resolvers.reaction import (
|
||||
create_reaction,
|
||||
delete_reaction,
|
||||
load_comment_ratings,
|
||||
load_comments_branch,
|
||||
load_reactions_by,
|
||||
load_shout_comments,
|
||||
load_shout_ratings,
|
||||
@@ -107,6 +108,7 @@ __all__ = [
|
||||
"load_shout_comments",
|
||||
"load_shout_ratings",
|
||||
"load_comment_ratings",
|
||||
"load_comments_branch",
|
||||
# notifier
|
||||
"load_notifications",
|
||||
"notifications_seen_thread",
|
||||
|
@@ -1,25 +1,196 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import desc, select, text
|
||||
from sqlalchemy import select, text
|
||||
|
||||
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,
|
||||
invalidate_cache_by_prefix,
|
||||
)
|
||||
from orm.author import Author
|
||||
from orm.shout import ShoutAuthor, ShoutTopic
|
||||
from orm.topic import Topic
|
||||
from resolvers.stat import get_with_stat
|
||||
from services.auth import login_required
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from services.schema import mutation, query
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
DEFAULT_COMMUNITIES = [1]
|
||||
|
||||
|
||||
# Вспомогательная функция для получения всех авторов без статистики
|
||||
async def get_all_authors():
|
||||
"""
|
||||
Получает всех авторов без статистики.
|
||||
Используется для случаев, когда нужен полный список авторов без дополнительной информации.
|
||||
|
||||
Returns:
|
||||
list: Список всех авторов без статистики
|
||||
"""
|
||||
cache_key = "authors:all:basic"
|
||||
|
||||
# Функция для получения всех авторов из БД
|
||||
async def fetch_all_authors():
|
||||
logger.debug("Получаем список всех авторов из БД и кешируем результат")
|
||||
|
||||
with local_session() as session:
|
||||
# Запрос на получение базовой информации об авторах
|
||||
authors_query = select(Author).where(Author.deleted_at.is_(None))
|
||||
authors = session.execute(authors_query).scalars().all()
|
||||
|
||||
# Преобразуем авторов в словари
|
||||
return [author.dict() for author in authors]
|
||||
|
||||
# Используем универсальную функцию для кеширования запросов
|
||||
return await cached_query(cache_key, fetch_all_authors)
|
||||
|
||||
|
||||
# Вспомогательная функция для получения авторов со статистикой с пагинацией
|
||||
async def get_authors_with_stats(limit=50, offset=0, by: Optional[str] = None):
|
||||
"""
|
||||
Получает авторов со статистикой с пагинацией.
|
||||
|
||||
Args:
|
||||
limit: Максимальное количество возвращаемых авторов
|
||||
offset: Смещение для пагинации
|
||||
by: Опциональный параметр сортировки (new/active)
|
||||
|
||||
Returns:
|
||||
list: Список авторов с их статистикой
|
||||
"""
|
||||
# Формируем ключ кеша с помощью универсальной функции
|
||||
cache_key = f"authors:stats:limit={limit}:offset={offset}"
|
||||
|
||||
# Функция для получения авторов из БД
|
||||
async def fetch_authors_with_stats():
|
||||
logger.debug(f"Выполняем запрос на получение авторов со статистикой: limit={limit}, offset={offset}, by={by}")
|
||||
|
||||
with local_session() as session:
|
||||
# Базовый запрос для получения авторов
|
||||
base_query = select(Author).where(Author.deleted_at.is_(None))
|
||||
|
||||
# Применяем сортировку
|
||||
if by:
|
||||
if isinstance(by, dict):
|
||||
# Обработка словаря параметров сортировки
|
||||
from sqlalchemy import asc, desc
|
||||
|
||||
for field, direction in by.items():
|
||||
column = getattr(Author, field, None)
|
||||
if column:
|
||||
if direction.lower() == "desc":
|
||||
base_query = base_query.order_by(desc(column))
|
||||
else:
|
||||
base_query = base_query.order_by(column)
|
||||
elif by == "new":
|
||||
base_query = base_query.order_by(desc(Author.created_at))
|
||||
elif by == "active":
|
||||
base_query = base_query.order_by(desc(Author.last_seen))
|
||||
else:
|
||||
# По умолчанию сортируем по времени создания
|
||||
base_query = base_query.order_by(desc(Author.created_at))
|
||||
else:
|
||||
base_query = base_query.order_by(desc(Author.created_at))
|
||||
|
||||
# Применяем лимит и смещение
|
||||
base_query = base_query.limit(limit).offset(offset)
|
||||
|
||||
# Получаем авторов
|
||||
authors = session.execute(base_query).scalars().all()
|
||||
author_ids = [author.id for author in authors]
|
||||
|
||||
if not author_ids:
|
||||
return []
|
||||
|
||||
# Оптимизированный запрос для получения статистики по публикациям для авторов
|
||||
shouts_stats_query = f"""
|
||||
SELECT sa.author, COUNT(DISTINCT s.id) as shouts_count
|
||||
FROM shout_author sa
|
||||
JOIN shout s ON sa.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL
|
||||
WHERE sa.author IN ({",".join(map(str, author_ids))})
|
||||
GROUP BY sa.author
|
||||
"""
|
||||
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query))}
|
||||
|
||||
# Запрос на получение статистики по подписчикам для авторов
|
||||
followers_stats_query = f"""
|
||||
SELECT author, COUNT(DISTINCT follower) as followers_count
|
||||
FROM author_follower
|
||||
WHERE author IN ({",".join(map(str, author_ids))})
|
||||
GROUP BY author
|
||||
"""
|
||||
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query))}
|
||||
|
||||
# Формируем результат с добавлением статистики
|
||||
result = []
|
||||
for author in authors:
|
||||
author_dict = author.dict()
|
||||
author_dict["stat"] = {
|
||||
"shouts": shouts_stats.get(author.id, 0),
|
||||
"followers": followers_stats.get(author.id, 0),
|
||||
}
|
||||
result.append(author_dict)
|
||||
|
||||
# Кешируем каждого автора отдельно для использования в других функциях
|
||||
await cache_author(author_dict)
|
||||
|
||||
return result
|
||||
|
||||
# Используем универсальную функцию для кеширования запросов
|
||||
return await cached_query(cache_key, fetch_authors_with_stats)
|
||||
|
||||
|
||||
# Функция для инвалидации кеша авторов
|
||||
async def invalidate_authors_cache(author_id=None):
|
||||
"""
|
||||
Инвалидирует кеши авторов при изменении данных.
|
||||
|
||||
Args:
|
||||
author_id: Опциональный ID автора для точечной инвалидации.
|
||||
Если не указан, инвалидируются все кеши авторов.
|
||||
"""
|
||||
if author_id:
|
||||
# Точечная инвалидация конкретного автора
|
||||
logger.debug(f"Инвалидация кеша для автора #{author_id}")
|
||||
specific_keys = [
|
||||
f"author:id:{author_id}",
|
||||
f"author:followers:{author_id}",
|
||||
f"author:follows-authors:{author_id}",
|
||||
f"author:follows-topics:{author_id}",
|
||||
f"author:follows-shouts:{author_id}",
|
||||
]
|
||||
|
||||
# Получаем user_id автора, если есть
|
||||
with local_session() as session:
|
||||
author = session.query(Author).filter(Author.id == author_id).first()
|
||||
if author and author.user:
|
||||
specific_keys.append(f"author:user:{author.user.strip()}")
|
||||
|
||||
# Удаляем конкретные ключи
|
||||
for key in specific_keys:
|
||||
try:
|
||||
await redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {key}: {e}")
|
||||
|
||||
# Также ищем и удаляем ключи коллекций, содержащих данные об этом авторе
|
||||
collection_keys = await redis.execute("KEYS", "authors:stats:*")
|
||||
if collection_keys:
|
||||
await redis.execute("DEL", *collection_keys)
|
||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей авторов")
|
||||
else:
|
||||
# Общая инвалидация всех кешей авторов
|
||||
logger.debug("Полная инвалидация кеша авторов")
|
||||
await invalidate_cache_by_prefix("authors")
|
||||
|
||||
|
||||
@mutation.field("update_author")
|
||||
@login_required
|
||||
@@ -51,10 +222,14 @@ async def update_author(_, info, profile):
|
||||
|
||||
|
||||
@query.field("get_authors_all")
|
||||
def get_authors_all(_, _info):
|
||||
with local_session() as session:
|
||||
authors = session.query(Author).all()
|
||||
return authors
|
||||
async def get_authors_all(_, _info):
|
||||
"""
|
||||
Получает список всех авторов без статистики.
|
||||
|
||||
Returns:
|
||||
list: Список всех авторов
|
||||
"""
|
||||
return await get_all_authors()
|
||||
|
||||
|
||||
@query.field("get_author")
|
||||
@@ -105,145 +280,105 @@ async def get_author_id(_, _info, user: str):
|
||||
asyncio.create_task(cache_author(author_dict))
|
||||
return author_with_stat
|
||||
except Exception as exc:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.error(exc)
|
||||
logger.error(f"Error getting author: {exc}")
|
||||
return None
|
||||
|
||||
|
||||
@query.field("load_authors_by")
|
||||
async def load_authors_by(_, _info, by, limit, offset):
|
||||
logger.debug(f"loading authors by {by}")
|
||||
authors_query = select(Author)
|
||||
"""
|
||||
Загружает авторов по заданному критерию с пагинацией.
|
||||
|
||||
if by.get("slug"):
|
||||
authors_query = authors_query.filter(Author.slug.ilike(f"%{by['slug']}%"))
|
||||
elif by.get("name"):
|
||||
authors_query = authors_query.filter(Author.name.ilike(f"%{by['name']}%"))
|
||||
elif by.get("topic"):
|
||||
authors_query = (
|
||||
authors_query.join(ShoutAuthor) # Первое соединение ShoutAuthor
|
||||
.join(ShoutTopic, ShoutAuthor.shout == ShoutTopic.shout)
|
||||
.join(Topic, ShoutTopic.topic == Topic.id)
|
||||
.filter(Topic.slug == str(by["topic"]))
|
||||
)
|
||||
Args:
|
||||
by: Критерий сортировки авторов (new/active)
|
||||
limit: Максимальное количество возвращаемых авторов
|
||||
offset: Смещение для пагинации
|
||||
|
||||
if by.get("last_seen"): # в unix time
|
||||
before = int(time.time()) - by["last_seen"]
|
||||
authors_query = authors_query.filter(Author.last_seen > before)
|
||||
elif by.get("created_at"): # в unix time
|
||||
before = int(time.time()) - by["created_at"]
|
||||
authors_query = authors_query.filter(Author.created_at > before)
|
||||
|
||||
authors_query = authors_query.limit(limit).offset(offset)
|
||||
|
||||
with local_session() as session:
|
||||
authors_nostat = session.execute(authors_query).all()
|
||||
authors = []
|
||||
for a in authors_nostat:
|
||||
if isinstance(a, Author):
|
||||
author_dict = await get_cached_author(a.id, get_with_stat)
|
||||
if author_dict and isinstance(author_dict.get("shouts"), int):
|
||||
authors.append(author_dict)
|
||||
|
||||
# order
|
||||
order = by.get("order")
|
||||
if order in ["shouts", "followers"]:
|
||||
authors_query = authors_query.order_by(desc(text(f"{order}_stat")))
|
||||
|
||||
# group by
|
||||
authors = get_with_stat(authors_query)
|
||||
return authors or []
|
||||
Returns:
|
||||
list: Список авторов с учетом критерия
|
||||
"""
|
||||
# Используем оптимизированную функцию для получения авторов
|
||||
return await get_authors_with_stats(limit, offset, by)
|
||||
|
||||
|
||||
def get_author_id_from(slug="", user=None, author_id=None):
|
||||
if not slug and not user and not author_id:
|
||||
raise ValueError("One of slug, user, or author_id must be provided")
|
||||
|
||||
author_query = select(Author.id)
|
||||
if user:
|
||||
author_query = author_query.filter(Author.user == user)
|
||||
elif slug:
|
||||
author_query = author_query.filter(Author.slug == slug)
|
||||
elif author_id:
|
||||
author_query = author_query.filter(Author.id == author_id)
|
||||
|
||||
with local_session() as session:
|
||||
author_id_result = session.execute(author_query).first()
|
||||
author_id = author_id_result[0] if author_id_result else None
|
||||
|
||||
if not author_id:
|
||||
raise ValueError("Author not found")
|
||||
|
||||
try:
|
||||
author_id = None
|
||||
if author_id:
|
||||
return author_id
|
||||
with local_session() as session:
|
||||
author = None
|
||||
if slug:
|
||||
author = session.query(Author).filter(Author.slug == slug).first()
|
||||
if author:
|
||||
author_id = author.id
|
||||
return author_id
|
||||
if user:
|
||||
author = session.query(Author).filter(Author.user == user).first()
|
||||
if author:
|
||||
author_id = author.id
|
||||
except Exception as exc:
|
||||
logger.error(exc)
|
||||
return author_id
|
||||
|
||||
|
||||
@query.field("get_author_follows")
|
||||
async def get_author_follows(_, _info, slug="", user=None, author_id=0):
|
||||
try:
|
||||
author_id = get_author_id_from(slug, user, author_id)
|
||||
logger.debug(f"getting follows for @{slug}")
|
||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||
if not author_id:
|
||||
return {}
|
||||
|
||||
if bool(author_id):
|
||||
logger.debug(f"getting {author_id} follows authors")
|
||||
authors = await get_cached_follower_authors(author_id)
|
||||
topics = await get_cached_follower_topics(author_id)
|
||||
return {
|
||||
"topics": topics,
|
||||
"authors": authors,
|
||||
"communities": [{"id": 1, "name": "Дискурс", "slug": "discours", "pic": ""}],
|
||||
}
|
||||
except Exception:
|
||||
import traceback
|
||||
followed_authors = await get_cached_follower_authors(author_id)
|
||||
followed_topics = await get_cached_follower_topics(author_id)
|
||||
|
||||
traceback.print_exc()
|
||||
return {"error": "Author not found"}
|
||||
# TODO: Get followed communities too
|
||||
return {
|
||||
"authors": followed_authors,
|
||||
"topics": followed_topics,
|
||||
"communities": DEFAULT_COMMUNITIES,
|
||||
"shouts": [],
|
||||
}
|
||||
|
||||
|
||||
@query.field("get_author_follows_topics")
|
||||
async def get_author_follows_topics(_, _info, slug="", user=None, author_id=None):
|
||||
try:
|
||||
follower_id = get_author_id_from(slug, user, author_id)
|
||||
topics = await get_cached_follower_topics(follower_id)
|
||||
return topics
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
logger.debug(f"getting followed topics for @{slug}")
|
||||
author_id = get_author_id_from(slug=slug, user=user, author_id=author_id)
|
||||
if not author_id:
|
||||
return []
|
||||
followed_topics = await get_cached_follower_topics(author_id)
|
||||
return followed_topics
|
||||
|
||||
|
||||
@query.field("get_author_follows_authors")
|
||||
async def get_author_follows_authors(_, _info, slug="", user=None, author_id=None):
|
||||
try:
|
||||
follower_id = get_author_id_from(slug, user, author_id)
|
||||
return await get_cached_follower_authors(follower_id)
|
||||
except Exception:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
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 = await get_cached_follower_authors(author_id)
|
||||
return followed_authors
|
||||
|
||||
|
||||
def create_author(user_id: str, slug: str, name: str = ""):
|
||||
author = Author()
|
||||
author.user = user_id # Связь с user_id из системы авторизации
|
||||
author.slug = slug # Идентификатор из системы авторизации
|
||||
author.created_at = author.updated_at = int(time.time())
|
||||
author.name = name or slug # если не указано
|
||||
|
||||
with local_session() as session:
|
||||
try:
|
||||
author = None
|
||||
if user_id:
|
||||
author = session.query(Author).filter(Author.user == user_id).first()
|
||||
elif slug:
|
||||
author = session.query(Author).filter(Author.slug == slug).first()
|
||||
if not author:
|
||||
new_author = Author(user=user_id, slug=slug, name=name)
|
||||
session.add(new_author)
|
||||
session.commit()
|
||||
logger.info(f"author created by webhook {new_author.dict()}")
|
||||
except Exception as exc:
|
||||
logger.debug(exc)
|
||||
session.add(author)
|
||||
session.commit()
|
||||
return author
|
||||
|
||||
|
||||
@query.field("get_author_followers")
|
||||
async def get_author_followers(_, _info, slug: str = "", user: str = "", author_id: int = 0):
|
||||
logger.debug(f"getting followers for @{slug}")
|
||||
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)
|
||||
followers = []
|
||||
if author_id:
|
||||
followers = await get_cached_author_followers(author_id)
|
||||
if not author_id:
|
||||
return []
|
||||
followers = await get_cached_author_followers(author_id)
|
||||
return followers
|
||||
|
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
from operator import or_
|
||||
|
||||
from sqlalchemy.sql import and_
|
||||
|
||||
@@ -55,7 +56,11 @@ async def load_drafts(_, info):
|
||||
return {"error": "User ID and author ID are required"}
|
||||
|
||||
with local_session() as session:
|
||||
drafts = session.query(Draft).filter(Draft.authors.any(Author.id == author_id)).all()
|
||||
drafts = (
|
||||
session.query(Draft)
|
||||
.filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id))
|
||||
.all()
|
||||
)
|
||||
return {"drafts": drafts}
|
||||
|
||||
|
||||
@@ -96,7 +101,7 @@ async def create_draft(_, info, draft_input):
|
||||
# Проверяем обязательные поля
|
||||
if "body" not in draft_input or not draft_input["body"]:
|
||||
draft_input["body"] = "" # Пустая строка вместо NULL
|
||||
|
||||
|
||||
if "title" not in draft_input or not draft_input["title"]:
|
||||
draft_input["title"] = "" # Пустая строка вместо NULL
|
||||
|
||||
@@ -120,24 +125,34 @@ async def create_draft(_, info, draft_input):
|
||||
|
||||
@mutation.field("update_draft")
|
||||
@login_required
|
||||
async def update_draft(_, info, draft_input):
|
||||
async def update_draft(_, info, draft_id: int, draft_input):
|
||||
"""Обновляет черновик публикации.
|
||||
|
||||
Args:
|
||||
draft_id: ID черновика для обновления
|
||||
draft_input: Данные для обновления черновика
|
||||
|
||||
Returns:
|
||||
dict: Обновленный черновик или сообщение об ошибке
|
||||
"""
|
||||
user_id = info.context.get("user_id")
|
||||
author_dict = info.context.get("author", {})
|
||||
author_id = author_dict.get("id")
|
||||
draft_id = draft_input.get("id")
|
||||
if not draft_id:
|
||||
return {"error": "Draft ID is required"}
|
||||
|
||||
if not user_id or not author_id:
|
||||
return {"error": "Author ID are required"}
|
||||
|
||||
with local_session() as session:
|
||||
draft = session.query(Draft).filter(Draft.id == draft_id).first()
|
||||
del draft_input["id"]
|
||||
Draft.update(draft, {**draft_input})
|
||||
if not draft:
|
||||
return {"error": "Draft not found"}
|
||||
|
||||
draft.updated_at = int(time.time())
|
||||
Draft.update(draft, draft_input)
|
||||
# Set updated_at and updated_by from the authenticated user
|
||||
current_time = int(time.time())
|
||||
draft.updated_at = current_time
|
||||
draft.updated_by = author_id
|
||||
|
||||
session.commit()
|
||||
return {"draft": draft}
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
import json
|
||||
import time
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, desc, select
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy.sql.functions import coalesce
|
||||
@@ -106,7 +106,7 @@ async def get_my_shout(_, info, shout_id: int):
|
||||
if hasattr(shout, "media") and shout.media:
|
||||
if isinstance(shout.media, str):
|
||||
try:
|
||||
shout.media = json.loads(shout.media)
|
||||
shout.media = orjson.loads(shout.media)
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing shout media: {e}")
|
||||
shout.media = []
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import json
|
||||
import time
|
||||
from typing import List, Tuple
|
||||
|
||||
import orjson
|
||||
from sqlalchemy import and_, select
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import aliased
|
||||
@@ -115,7 +115,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
|
||||
if (groups_amount + offset) >= limit:
|
||||
break
|
||||
|
||||
payload = json.loads(str(notification.payload))
|
||||
payload = orjson.loads(str(notification.payload))
|
||||
|
||||
if str(notification.entity) == NotificationEntity.SHOUT.value:
|
||||
shout = payload
|
||||
@@ -177,7 +177,7 @@ def get_notifications_grouped(author_id: int, after: int = 0, limit: int = 10, o
|
||||
|
||||
elif str(notification.entity) == "follower":
|
||||
thread_id = "followers"
|
||||
follower = json.loads(payload)
|
||||
follower = orjson.loads(payload)
|
||||
group = groups_by_thread.get(thread_id)
|
||||
if group:
|
||||
if str(notification.action) == "follow":
|
||||
@@ -293,11 +293,11 @@ async def notifications_seen_thread(_, info, thread: str, after: int):
|
||||
)
|
||||
exclude = set()
|
||||
for nr in removed_reaction_notifications:
|
||||
reaction = json.loads(str(nr.payload))
|
||||
reaction = orjson.loads(str(nr.payload))
|
||||
reaction_id = reaction.get("id")
|
||||
exclude.add(reaction_id)
|
||||
for n in new_reaction_notifications:
|
||||
reaction = json.loads(str(n.payload))
|
||||
reaction = orjson.loads(str(n.payload))
|
||||
reaction_id = reaction.get("id")
|
||||
if (
|
||||
reaction_id not in exclude
|
||||
|
@@ -67,50 +67,58 @@ def add_reaction_stat_columns(q):
|
||||
return q
|
||||
|
||||
|
||||
def get_reactions_with_stat(q, limit, offset):
|
||||
def get_reactions_with_stat(q, limit=10, offset=0):
|
||||
"""
|
||||
Execute the reaction query and retrieve reactions with statistics.
|
||||
|
||||
:param q: Query with reactions and statistics.
|
||||
:param limit: Number of reactions to load.
|
||||
:param offset: Pagination offset.
|
||||
:return: List of reactions.
|
||||
:return: List of reactions as dictionaries.
|
||||
|
||||
>>> get_reactions_with_stat(q, 10, 0) # doctest: +SKIP
|
||||
[{'id': 1, 'body': 'Текст комментария', 'stat': {'rating': 5, 'comments_count': 3}, ...}]
|
||||
"""
|
||||
q = q.limit(limit).offset(offset)
|
||||
reactions = []
|
||||
|
||||
with local_session() as session:
|
||||
result_rows = session.execute(q)
|
||||
for reaction, author, shout, commented_stat, rating_stat in result_rows:
|
||||
for reaction, author, shout, comments_count, rating_stat in result_rows:
|
||||
# Пропускаем реакции с отсутствующими shout или author
|
||||
if not shout or not author:
|
||||
logger.error(f"Пропущена реакция из-за отсутствия shout или author: {reaction.dict()}")
|
||||
continue
|
||||
|
||||
reaction.created_by = author.dict()
|
||||
reaction.shout = shout.dict()
|
||||
reaction.stat = {"rating": rating_stat, "comments": commented_stat}
|
||||
reactions.append(reaction)
|
||||
# Преобразуем Reaction в словарь для доступа по ключу
|
||||
reaction_dict = reaction.dict()
|
||||
reaction_dict["created_by"] = author.dict()
|
||||
reaction_dict["shout"] = shout.dict()
|
||||
reaction_dict["stat"] = {"rating": rating_stat, "comments_count": comments_count}
|
||||
reactions.append(reaction_dict)
|
||||
|
||||
return reactions
|
||||
|
||||
|
||||
def is_featured_author(session, author_id) -> bool:
|
||||
"""
|
||||
Check if an author has at least one featured article.
|
||||
Check if an author has at least one non-deleted featured article.
|
||||
|
||||
:param session: Database session.
|
||||
:param author_id: Author ID.
|
||||
:return: True if the author has a featured article, else False.
|
||||
"""
|
||||
return session.query(
|
||||
session.query(Shout).where(Shout.authors.any(id=author_id)).filter(Shout.featured_at.is_not(None)).exists()
|
||||
session.query(Shout)
|
||||
.where(Shout.authors.any(id=author_id))
|
||||
.filter(Shout.featured_at.is_not(None), Shout.deleted_at.is_(None))
|
||||
.exists()
|
||||
).scalar()
|
||||
|
||||
|
||||
def check_to_feature(session, approver_id, reaction) -> bool:
|
||||
"""
|
||||
Make a shout featured if it receives more than 4 votes.
|
||||
Make a shout featured if it receives more than 4 votes from authors.
|
||||
|
||||
:param session: Database session.
|
||||
:param approver_id: Approver author ID.
|
||||
@@ -118,46 +126,78 @@ def check_to_feature(session, approver_id, reaction) -> bool:
|
||||
:return: True if shout should be featured, else False.
|
||||
"""
|
||||
if not reaction.reply_to and is_positive(reaction.kind):
|
||||
approvers = {approver_id}
|
||||
# Count the number of approvers
|
||||
# Проверяем, не содержит ли пост более 20% дизлайков
|
||||
# Если да, то не должен быть featured независимо от количества лайков
|
||||
if check_to_unfeature(session, reaction):
|
||||
return False
|
||||
|
||||
# Собираем всех авторов, поставивших лайк
|
||||
author_approvers = set()
|
||||
reacted_readers = (
|
||||
session.query(Reaction.created_by)
|
||||
.filter(Reaction.shout == reaction.shout, is_positive(Reaction.kind), Reaction.deleted_at.is_(None))
|
||||
.filter(
|
||||
Reaction.shout == reaction.shout,
|
||||
is_positive(Reaction.kind),
|
||||
# Рейтинги (LIKE, DISLIKE) физически удаляются, поэтому фильтр deleted_at не нужен
|
||||
)
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
|
||||
for reader_id in reacted_readers:
|
||||
# Добавляем текущего одобряющего
|
||||
approver = session.query(Author).filter(Author.id == approver_id).first()
|
||||
if approver and is_featured_author(session, approver_id):
|
||||
author_approvers.add(approver_id)
|
||||
|
||||
# Проверяем, есть ли у реагировавших авторов featured публикации
|
||||
for (reader_id,) in reacted_readers:
|
||||
if is_featured_author(session, reader_id):
|
||||
approvers.add(reader_id)
|
||||
return len(approvers) > 4
|
||||
author_approvers.add(reader_id)
|
||||
|
||||
# Публикация становится featured при наличии более 4 лайков от авторов
|
||||
logger.debug(f"Публикация {reaction.shout} имеет {len(author_approvers)} лайков от авторов")
|
||||
return len(author_approvers) > 4
|
||||
return False
|
||||
|
||||
|
||||
def check_to_unfeature(session, rejecter_id, reaction) -> bool:
|
||||
def check_to_unfeature(session, reaction) -> bool:
|
||||
"""
|
||||
Unfeature a shout if 20% of reactions are negative.
|
||||
|
||||
:param session: Database session.
|
||||
:param rejecter_id: Rejecter author ID.
|
||||
:param reaction: Reaction object.
|
||||
:return: True if shout should be unfeatured, else False.
|
||||
"""
|
||||
if not reaction.reply_to and is_negative(reaction.kind):
|
||||
if not reaction.reply_to:
|
||||
# Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк
|
||||
total_reactions = (
|
||||
session.query(Reaction)
|
||||
.filter(
|
||||
Reaction.shout == reaction.shout, Reaction.kind.in_(RATING_REACTIONS), Reaction.deleted_at.is_(None)
|
||||
Reaction.shout == reaction.shout,
|
||||
Reaction.reply_to.is_(None),
|
||||
Reaction.kind.in_(RATING_REACTIONS),
|
||||
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
negative_reactions = (
|
||||
session.query(Reaction)
|
||||
.filter(Reaction.shout == reaction.shout, is_negative(Reaction.kind), Reaction.deleted_at.is_(None))
|
||||
.filter(
|
||||
Reaction.shout == reaction.shout,
|
||||
is_negative(Reaction.kind),
|
||||
Reaction.reply_to.is_(None),
|
||||
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
|
||||
)
|
||||
.count()
|
||||
)
|
||||
|
||||
return total_reactions > 0 and (negative_reactions / total_reactions) >= 0.2
|
||||
# Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций
|
||||
negative_ratio = negative_reactions / total_reactions if total_reactions > 0 else 0
|
||||
logger.debug(
|
||||
f"Публикация {reaction.shout}: {negative_reactions}/{total_reactions} отрицательных реакций ({negative_ratio:.2%})"
|
||||
)
|
||||
return total_reactions > 0 and negative_ratio >= 0.2
|
||||
return False
|
||||
|
||||
|
||||
@@ -196,8 +236,8 @@ async def _create_reaction(session, shout_id: int, is_author: bool, author_id: i
|
||||
Create a new reaction and perform related actions such as updating counters and notification.
|
||||
|
||||
:param session: Database session.
|
||||
:param info: GraphQL context info.
|
||||
:param shout: Shout object.
|
||||
:param shout_id: Shout ID.
|
||||
:param is_author: Flag indicating if the user is the author of the shout.
|
||||
:param author_id: Author ID.
|
||||
:param reaction: Dictionary with reaction data.
|
||||
:return: Dictionary with created reaction data.
|
||||
@@ -217,10 +257,14 @@ async def _create_reaction(session, shout_id: int, is_author: bool, author_id: i
|
||||
|
||||
# Handle rating
|
||||
if r.kind in RATING_REACTIONS:
|
||||
if check_to_unfeature(session, author_id, r):
|
||||
# Проверяем сначала условие для unfeature (дизлайки имеют приоритет)
|
||||
if check_to_unfeature(session, r):
|
||||
set_unfeatured(session, shout_id)
|
||||
logger.info(f"Публикация {shout_id} потеряла статус featured из-за высокого процента дизлайков")
|
||||
# Только если не было unfeature, проверяем условие для feature
|
||||
elif check_to_feature(session, author_id, r):
|
||||
await set_featured(session, shout_id)
|
||||
logger.info(f"Публикация {shout_id} получила статус featured благодаря лайкам от авторов")
|
||||
|
||||
# Notify creation
|
||||
await notify_reaction(rdict, "create")
|
||||
@@ -354,7 +398,7 @@ async def update_reaction(_, info, reaction):
|
||||
|
||||
result = session.execute(reaction_query).unique().first()
|
||||
if result:
|
||||
r, author, shout, commented_stat, rating_stat = result
|
||||
r, author, _shout, comments_count, rating_stat = result
|
||||
if not r or not author:
|
||||
return {"error": "Invalid reaction ID or unauthorized"}
|
||||
|
||||
@@ -369,7 +413,7 @@ async def update_reaction(_, info, reaction):
|
||||
session.commit()
|
||||
|
||||
r.stat = {
|
||||
"commented": commented_stat,
|
||||
"comments_count": comments_count,
|
||||
"rating": rating_stat,
|
||||
}
|
||||
|
||||
@@ -406,15 +450,24 @@ async def delete_reaction(_, info, reaction_id: int):
|
||||
if r.created_by != author_id and "editor" not in roles:
|
||||
return {"error": "Access denied"}
|
||||
|
||||
logger.debug(f"{user_id} user removing his #{reaction_id} reaction")
|
||||
reaction_dict = r.dict()
|
||||
session.delete(r)
|
||||
session.commit()
|
||||
|
||||
# Update author stat
|
||||
if r.kind == ReactionKind.COMMENT.value:
|
||||
r.deleted_at = int(time.time())
|
||||
update_author_stat(author.id)
|
||||
session.add(r)
|
||||
session.commit()
|
||||
elif r.kind == ReactionKind.PROPOSE.value:
|
||||
r.deleted_at = int(time.time())
|
||||
session.add(r)
|
||||
session.commit()
|
||||
# TODO: add more reaction types here
|
||||
else:
|
||||
logger.debug(f"{user_id} user removing his #{reaction_id} reaction")
|
||||
session.delete(r)
|
||||
session.commit()
|
||||
if check_to_unfeature(session, r):
|
||||
set_unfeatured(session, r.shout)
|
||||
|
||||
reaction_dict = r.dict()
|
||||
await notify_reaction(reaction_dict, "delete")
|
||||
|
||||
return {"error": None, "reaction": reaction_dict}
|
||||
@@ -485,7 +538,9 @@ async def load_reactions_by(_, _info, by, limit=50, offset=0):
|
||||
# Add statistics and apply filters
|
||||
q = add_reaction_stat_columns(q)
|
||||
q = apply_reaction_filters(by, q)
|
||||
q = q.where(Reaction.deleted_at.is_(None))
|
||||
|
||||
# Include reactions with deleted_at for building comment trees
|
||||
# q = q.where(Reaction.deleted_at.is_(None))
|
||||
|
||||
# Group and sort
|
||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||
@@ -562,24 +617,22 @@ async def load_shout_comments(_, info, shout: int, limit=50, offset=0):
|
||||
@query.field("load_comment_ratings")
|
||||
async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
||||
"""
|
||||
Load ratings for a specified comment with pagination and statistics.
|
||||
Load ratings for a specified comment with pagination.
|
||||
|
||||
:param info: GraphQL context info.
|
||||
:param comment: Comment ID.
|
||||
:param limit: Number of ratings to load.
|
||||
:param offset: Pagination offset.
|
||||
:return: List of reactions.
|
||||
:return: List of ratings.
|
||||
"""
|
||||
q = query_reactions()
|
||||
|
||||
q = add_reaction_stat_columns(q)
|
||||
|
||||
# Filter, group, sort, limit, offset
|
||||
q = q.filter(
|
||||
and_(
|
||||
Reaction.deleted_at.is_(None),
|
||||
Reaction.reply_to == comment,
|
||||
Reaction.kind == ReactionKind.COMMENT.value,
|
||||
Reaction.kind.in_(RATING_REACTIONS),
|
||||
)
|
||||
)
|
||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||
@@ -587,3 +640,187 @@ async def load_comment_ratings(_, info, comment: int, limit=50, offset=0):
|
||||
|
||||
# Retrieve and return reactions
|
||||
return get_reactions_with_stat(q, limit, offset)
|
||||
|
||||
|
||||
@query.field("load_comments_branch")
|
||||
async def load_comments_branch(
|
||||
_,
|
||||
_info,
|
||||
shout: int,
|
||||
parent_id: int | None = None,
|
||||
limit=10,
|
||||
offset=0,
|
||||
sort="newest",
|
||||
children_limit=3,
|
||||
children_offset=0,
|
||||
):
|
||||
"""
|
||||
Загружает иерархические комментарии с возможностью пагинации корневых и дочерних.
|
||||
|
||||
:param info: GraphQL context info.
|
||||
:param shout: ID статьи.
|
||||
:param parent_id: ID родительского комментария (None для корневых).
|
||||
:param limit: Количество комментариев для загрузки.
|
||||
:param offset: Смещение для пагинации.
|
||||
:param sort: Порядок сортировки ('newest', 'oldest', 'like').
|
||||
:param children_limit: Максимальное количество дочерних комментариев.
|
||||
:param children_offset: Смещение для дочерних комментариев.
|
||||
:return: Список комментариев с дочерними.
|
||||
"""
|
||||
# Создаем базовый запрос
|
||||
q = query_reactions()
|
||||
q = add_reaction_stat_columns(q)
|
||||
|
||||
# Фильтруем по статье и типу (комментарии)
|
||||
q = q.filter(
|
||||
and_(
|
||||
Reaction.deleted_at.is_(None),
|
||||
Reaction.shout == shout,
|
||||
Reaction.kind == ReactionKind.COMMENT.value,
|
||||
)
|
||||
)
|
||||
|
||||
# Фильтруем по родительскому ID
|
||||
if parent_id is None:
|
||||
# Загружаем только корневые комментарии
|
||||
q = q.filter(Reaction.reply_to.is_(None))
|
||||
else:
|
||||
# Загружаем только прямые ответы на указанный комментарий
|
||||
q = q.filter(Reaction.reply_to == parent_id)
|
||||
|
||||
# Сортировка и группировка
|
||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||
|
||||
# Определяем сортировку
|
||||
order_by_stmt = None
|
||||
if sort.lower() == "oldest":
|
||||
order_by_stmt = asc(Reaction.created_at)
|
||||
elif sort.lower() == "like":
|
||||
order_by_stmt = desc("rating_stat")
|
||||
else: # "newest" по умолчанию
|
||||
order_by_stmt = desc(Reaction.created_at)
|
||||
|
||||
q = q.order_by(order_by_stmt)
|
||||
|
||||
# Выполняем запрос для получения комментариев
|
||||
comments = get_reactions_with_stat(q, limit, offset)
|
||||
|
||||
# Если комментарии найдены, загружаем дочерние и количество ответов
|
||||
if comments:
|
||||
# Загружаем количество ответов для каждого комментария
|
||||
await load_replies_count(comments)
|
||||
|
||||
# Загружаем дочерние комментарии
|
||||
await load_first_replies(comments, children_limit, children_offset, sort)
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
async def load_replies_count(comments):
|
||||
"""
|
||||
Загружает количество ответов для списка комментариев и обновляет поле stat.comments_count.
|
||||
|
||||
:param comments: Список комментариев, для которых нужно загрузить количество ответов.
|
||||
"""
|
||||
if not comments:
|
||||
return
|
||||
|
||||
comment_ids = [comment["id"] for comment in comments]
|
||||
|
||||
# Запрос для подсчета количества ответов
|
||||
q = (
|
||||
select(Reaction.reply_to.label("parent_id"), func.count().label("count"))
|
||||
.where(
|
||||
and_(
|
||||
Reaction.reply_to.in_(comment_ids),
|
||||
Reaction.deleted_at.is_(None),
|
||||
Reaction.kind == ReactionKind.COMMENT.value,
|
||||
)
|
||||
)
|
||||
.group_by(Reaction.reply_to)
|
||||
)
|
||||
|
||||
# Выполняем запрос
|
||||
with local_session() as session:
|
||||
result = session.execute(q).fetchall()
|
||||
|
||||
# Создаем словарь {parent_id: count}
|
||||
replies_count = {row[0]: row[1] for row in result}
|
||||
|
||||
# Добавляем значения в комментарии
|
||||
for comment in comments:
|
||||
if "stat" not in comment:
|
||||
comment["stat"] = {}
|
||||
|
||||
# Обновляем счетчик комментариев в stat
|
||||
comment["stat"]["comments_count"] = replies_count.get(comment["id"], 0)
|
||||
|
||||
|
||||
async def load_first_replies(comments, limit, offset, sort="newest"):
|
||||
"""
|
||||
Загружает первые N ответов для каждого комментария.
|
||||
|
||||
:param comments: Список комментариев, для которых нужно загрузить ответы.
|
||||
:param limit: Максимальное количество ответов для каждого комментария.
|
||||
:param offset: Смещение для пагинации дочерних комментариев.
|
||||
:param sort: Порядок сортировки ответов.
|
||||
"""
|
||||
if not comments or limit <= 0:
|
||||
return
|
||||
|
||||
# Собираем ID комментариев
|
||||
comment_ids = [comment["id"] for comment in comments]
|
||||
|
||||
# Базовый запрос для загрузки ответов
|
||||
q = query_reactions()
|
||||
q = add_reaction_stat_columns(q)
|
||||
|
||||
# Фильтрация: только ответы на указанные комментарии
|
||||
q = q.filter(
|
||||
and_(
|
||||
Reaction.reply_to.in_(comment_ids),
|
||||
Reaction.deleted_at.is_(None),
|
||||
Reaction.kind == ReactionKind.COMMENT.value,
|
||||
)
|
||||
)
|
||||
|
||||
# Группировка
|
||||
q = q.group_by(Reaction.id, Author.id, Shout.id)
|
||||
|
||||
# Определяем сортировку
|
||||
order_by_stmt = None
|
||||
if sort.lower() == "oldest":
|
||||
order_by_stmt = asc(Reaction.created_at)
|
||||
elif sort.lower() == "like":
|
||||
order_by_stmt = desc("rating_stat")
|
||||
else: # "newest" по умолчанию
|
||||
order_by_stmt = desc(Reaction.created_at)
|
||||
|
||||
q = q.order_by(order_by_stmt, Reaction.reply_to)
|
||||
|
||||
# Выполняем запрос - указываем limit для неограниченного количества ответов
|
||||
# но не более 100 на родительский комментарий
|
||||
replies = get_reactions_with_stat(q, limit=100, offset=0)
|
||||
|
||||
# Группируем ответы по родительским ID
|
||||
replies_by_parent = {}
|
||||
for reply in replies:
|
||||
parent_id = reply.get("reply_to")
|
||||
if parent_id not in replies_by_parent:
|
||||
replies_by_parent[parent_id] = []
|
||||
replies_by_parent[parent_id].append(reply)
|
||||
|
||||
# Добавляем ответы к соответствующим комментариям с учетом смещения и лимита
|
||||
for comment in comments:
|
||||
comment_id = comment["id"]
|
||||
if comment_id in replies_by_parent:
|
||||
parent_replies = replies_by_parent[comment_id]
|
||||
# Применяем смещение и лимит
|
||||
comment["first_replies"] = parent_replies[offset : offset + limit]
|
||||
else:
|
||||
comment["first_replies"] = []
|
||||
|
||||
# Загружаем количество ответов для дочерних комментариев
|
||||
all_replies = [reply for replies in replies_by_parent.values() for reply in replies]
|
||||
if all_replies:
|
||||
await load_replies_count(all_replies)
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
|
||||
import orjson
|
||||
from graphql import GraphQLResolveInfo
|
||||
from sqlalchemy import and_, nulls_last, text
|
||||
from sqlalchemy.orm import aliased
|
||||
@@ -222,16 +221,16 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
||||
if has_field(info, "stat"):
|
||||
stat = {}
|
||||
if isinstance(row.stat, str):
|
||||
stat = json.loads(row.stat)
|
||||
stat = orjson.loads(row.stat)
|
||||
elif isinstance(row.stat, dict):
|
||||
stat = row.stat
|
||||
viewed = ViewedStorage.get_shout(shout_id=shout_id) or 0
|
||||
shout_dict["stat"] = {**stat, "viewed": viewed, "commented": stat.get("comments_count", 0)}
|
||||
shout_dict["stat"] = {**stat, "viewed": viewed}
|
||||
|
||||
# Обработка main_topic и topics
|
||||
topics = None
|
||||
if has_field(info, "topics") and hasattr(row, "topics"):
|
||||
topics = json.loads(row.topics) if isinstance(row.topics, str) else row.topics
|
||||
topics = orjson.loads(row.topics) if isinstance(row.topics, str) else row.topics
|
||||
# logger.debug(f"Shout#{shout_id} topics: {topics}")
|
||||
shout_dict["topics"] = topics
|
||||
|
||||
@@ -240,7 +239,7 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
||||
if hasattr(row, "main_topic"):
|
||||
# logger.debug(f"Raw main_topic for shout#{shout_id}: {row.main_topic}")
|
||||
main_topic = (
|
||||
json.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic
|
||||
orjson.loads(row.main_topic) if isinstance(row.main_topic, str) else row.main_topic
|
||||
)
|
||||
# logger.debug(f"Parsed main_topic for shout#{shout_id}: {main_topic}")
|
||||
|
||||
@@ -260,7 +259,7 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
||||
|
||||
if has_field(info, "authors") and hasattr(row, "authors"):
|
||||
shout_dict["authors"] = (
|
||||
json.loads(row.authors) if isinstance(row.authors, str) else row.authors
|
||||
orjson.loads(row.authors) if isinstance(row.authors, str) else row.authors
|
||||
)
|
||||
|
||||
if has_field(info, "media") and shout.media:
|
||||
@@ -268,8 +267,8 @@ def get_shouts_with_links(info, q, limit=20, offset=0):
|
||||
media_data = shout.media
|
||||
if isinstance(media_data, str):
|
||||
try:
|
||||
media_data = json.loads(media_data)
|
||||
except json.JSONDecodeError:
|
||||
media_data = orjson.loads(media_data)
|
||||
except orjson.JSONDecodeError:
|
||||
media_data = []
|
||||
shout_dict["media"] = [media_data] if isinstance(media_data, dict) else media_data
|
||||
|
||||
|
@@ -1,44 +1,223 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import desc, select, text
|
||||
|
||||
from cache.cache import (
|
||||
cache_topic,
|
||||
cached_query,
|
||||
get_cached_topic_authors,
|
||||
get_cached_topic_by_slug,
|
||||
get_cached_topic_followers,
|
||||
invalidate_cache_by_prefix,
|
||||
)
|
||||
from cache.memorycache import cache_region
|
||||
from orm.author import Author
|
||||
from orm.topic import Topic
|
||||
from resolvers.stat import get_with_stat
|
||||
from services.auth import login_required
|
||||
from services.db import local_session
|
||||
from services.redis import redis
|
||||
from services.schema import mutation, query
|
||||
from utils.logger import root_logger as logger
|
||||
|
||||
|
||||
# Вспомогательная функция для получения всех тем без статистики
|
||||
async def get_all_topics():
|
||||
"""
|
||||
Получает все темы без статистики.
|
||||
Используется для случаев, когда нужен полный список тем без дополнительной информации.
|
||||
|
||||
Returns:
|
||||
list: Список всех тем без статистики
|
||||
"""
|
||||
cache_key = "topics:all:basic"
|
||||
|
||||
# Функция для получения всех тем из БД
|
||||
async def fetch_all_topics():
|
||||
logger.debug("Получаем список всех тем из БД и кешируем результат")
|
||||
|
||||
with local_session() as session:
|
||||
# Запрос на получение базовой информации о темах
|
||||
topics_query = select(Topic)
|
||||
topics = session.execute(topics_query).scalars().all()
|
||||
|
||||
# Преобразуем темы в словари
|
||||
return [topic.dict() for topic in topics]
|
||||
|
||||
# Используем универсальную функцию для кеширования запросов
|
||||
return await cached_query(cache_key, fetch_all_topics)
|
||||
|
||||
|
||||
# Вспомогательная функция для получения тем со статистикой с пагинацией
|
||||
async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None):
|
||||
"""
|
||||
Получает темы со статистикой с пагинацией.
|
||||
|
||||
Args:
|
||||
limit: Максимальное количество возвращаемых тем
|
||||
offset: Смещение для пагинации
|
||||
community_id: Опциональный ID сообщества для фильтрации
|
||||
by: Опциональный параметр сортировки
|
||||
|
||||
Returns:
|
||||
list: Список тем с их статистикой
|
||||
"""
|
||||
# Формируем ключ кеша с помощью универсальной функции
|
||||
cache_key = f"topics:stats:limit={limit}:offset={offset}:community_id={community_id}"
|
||||
|
||||
# Функция для получения тем из БД
|
||||
async def fetch_topics_with_stats():
|
||||
logger.debug(f"Выполняем запрос на получение тем со статистикой: limit={limit}, offset={offset}")
|
||||
|
||||
with local_session() as session:
|
||||
# Базовый запрос для получения тем
|
||||
base_query = select(Topic)
|
||||
|
||||
# Добавляем фильтр по сообществу, если указан
|
||||
if community_id:
|
||||
base_query = base_query.where(Topic.community == community_id)
|
||||
|
||||
# Применяем сортировку на основе параметра by
|
||||
if by:
|
||||
if isinstance(by, dict):
|
||||
# Обработка словаря параметров сортировки
|
||||
for field, direction in by.items():
|
||||
column = getattr(Topic, field, None)
|
||||
if column:
|
||||
if direction.lower() == "desc":
|
||||
base_query = base_query.order_by(desc(column))
|
||||
else:
|
||||
base_query = base_query.order_by(column)
|
||||
elif by == "popular":
|
||||
# Сортировка по популярности (количеству публикаций)
|
||||
# Примечание: это требует дополнительного запроса или подзапроса
|
||||
base_query = base_query.order_by(
|
||||
desc(Topic.id)
|
||||
) # Временно, нужно заменить на proper implementation
|
||||
else:
|
||||
# По умолчанию сортируем по ID в обратном порядке
|
||||
base_query = base_query.order_by(desc(Topic.id))
|
||||
else:
|
||||
# По умолчанию сортируем по ID в обратном порядке
|
||||
base_query = base_query.order_by(desc(Topic.id))
|
||||
|
||||
# Применяем лимит и смещение
|
||||
base_query = base_query.limit(limit).offset(offset)
|
||||
|
||||
# Получаем темы
|
||||
topics = session.execute(base_query).scalars().all()
|
||||
topic_ids = [topic.id for topic in topics]
|
||||
|
||||
if not topic_ids:
|
||||
return []
|
||||
|
||||
# Запрос на получение статистики по публикациям для выбранных тем
|
||||
shouts_stats_query = f"""
|
||||
SELECT st.topic, COUNT(DISTINCT s.id) as shouts_count
|
||||
FROM shout_topic st
|
||||
JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL
|
||||
WHERE st.topic IN ({",".join(map(str, topic_ids))})
|
||||
GROUP BY st.topic
|
||||
"""
|
||||
shouts_stats = {row[0]: row[1] for row in session.execute(text(shouts_stats_query))}
|
||||
|
||||
# Запрос на получение статистики по подписчикам для выбранных тем
|
||||
followers_stats_query = f"""
|
||||
SELECT topic, COUNT(DISTINCT follower) as followers_count
|
||||
FROM topic_followers
|
||||
WHERE topic IN ({",".join(map(str, topic_ids))})
|
||||
GROUP BY topic
|
||||
"""
|
||||
followers_stats = {row[0]: row[1] for row in session.execute(text(followers_stats_query))}
|
||||
|
||||
# Формируем результат с добавлением статистики
|
||||
result = []
|
||||
for topic in topics:
|
||||
topic_dict = topic.dict()
|
||||
topic_dict["stat"] = {
|
||||
"shouts": shouts_stats.get(topic.id, 0),
|
||||
"followers": followers_stats.get(topic.id, 0),
|
||||
}
|
||||
result.append(topic_dict)
|
||||
|
||||
# Кешируем каждую тему отдельно для использования в других функциях
|
||||
await cache_topic(topic_dict)
|
||||
|
||||
return result
|
||||
|
||||
# Используем универсальную функцию для кеширования запросов
|
||||
return await cached_query(cache_key, fetch_topics_with_stats)
|
||||
|
||||
|
||||
# Функция для инвалидации кеша тем
|
||||
async def invalidate_topics_cache(topic_id=None):
|
||||
"""
|
||||
Инвалидирует кеши тем при изменении данных.
|
||||
|
||||
Args:
|
||||
topic_id: Опциональный ID темы для точечной инвалидации.
|
||||
Если не указан, инвалидируются все кеши тем.
|
||||
"""
|
||||
if topic_id:
|
||||
# Точечная инвалидация конкретной темы
|
||||
logger.debug(f"Инвалидация кеша для темы #{topic_id}")
|
||||
specific_keys = [
|
||||
f"topic:id:{topic_id}",
|
||||
f"topic:authors:{topic_id}",
|
||||
f"topic:followers:{topic_id}",
|
||||
f"topic_shouts_{topic_id}",
|
||||
]
|
||||
|
||||
# Получаем slug темы, если есть
|
||||
with local_session() as session:
|
||||
topic = session.query(Topic).filter(Topic.id == topic_id).first()
|
||||
if topic and topic.slug:
|
||||
specific_keys.append(f"topic:slug:{topic.slug}")
|
||||
|
||||
# Удаляем конкретные ключи
|
||||
for key in specific_keys:
|
||||
try:
|
||||
await redis.execute("DEL", key)
|
||||
logger.debug(f"Удален ключ кеша {key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при удалении ключа {key}: {e}")
|
||||
|
||||
# Также ищем и удаляем ключи коллекций, содержащих данные об этой теме
|
||||
collection_keys = await redis.execute("KEYS", "topics:stats:*")
|
||||
if collection_keys:
|
||||
await redis.execute("DEL", *collection_keys)
|
||||
logger.debug(f"Удалено {len(collection_keys)} коллекционных ключей тем")
|
||||
else:
|
||||
# Общая инвалидация всех кешей тем
|
||||
logger.debug("Полная инвалидация кеша тем")
|
||||
await invalidate_cache_by_prefix("topics")
|
||||
|
||||
|
||||
# Запрос на получение всех тем
|
||||
@query.field("get_topics_all")
|
||||
def get_topics_all(_, _info):
|
||||
cache_key = "get_topics_all" # Ключ для кеша
|
||||
async def get_topics_all(_, _info):
|
||||
"""
|
||||
Получает список всех тем без статистики.
|
||||
|
||||
@cache_region.cache_on_arguments(cache_key)
|
||||
def _get_topics_all():
|
||||
topics_query = select(Topic)
|
||||
return get_with_stat(topics_query) # Получение тем с учетом статистики
|
||||
|
||||
return _get_topics_all()
|
||||
Returns:
|
||||
list: Список всех тем
|
||||
"""
|
||||
return await get_all_topics()
|
||||
|
||||
|
||||
# Запрос на получение тем по сообществу
|
||||
@query.field("get_topics_by_community")
|
||||
def get_topics_by_community(_, _info, community_id: int):
|
||||
cache_key = f"get_topics_by_community_{community_id}" # Ключ для кеша
|
||||
async def get_topics_by_community(_, _info, community_id: int, limit=100, offset=0, by=None):
|
||||
"""
|
||||
Получает список тем, принадлежащих указанному сообществу с пагинацией и статистикой.
|
||||
|
||||
@cache_region.cache_on_arguments(cache_key)
|
||||
def _get_topics_by_community():
|
||||
topics_by_community_query = select(Topic).where(Topic.community == community_id)
|
||||
return get_with_stat(topics_by_community_query)
|
||||
Args:
|
||||
community_id: ID сообщества
|
||||
limit: Максимальное количество возвращаемых тем
|
||||
offset: Смещение для пагинации
|
||||
by: Опциональные параметры сортировки
|
||||
|
||||
return _get_topics_by_community()
|
||||
Returns:
|
||||
list: Список тем с их статистикой
|
||||
"""
|
||||
return await get_topics_with_stats(limit, offset, community_id, by)
|
||||
|
||||
|
||||
# Запрос на получение тем по автору
|
||||
@@ -74,6 +253,9 @@ async def create_topic(_, _info, topic_input):
|
||||
session.add(new_topic)
|
||||
session.commit()
|
||||
|
||||
# Инвалидируем кеш всех тем
|
||||
await invalidate_topics_cache()
|
||||
|
||||
return {"topic": new_topic}
|
||||
|
||||
|
||||
@@ -87,10 +269,19 @@ async def update_topic(_, _info, topic_input):
|
||||
if not topic:
|
||||
return {"error": "topic not found"}
|
||||
else:
|
||||
old_slug = topic.slug
|
||||
Topic.update(topic, topic_input)
|
||||
session.add(topic)
|
||||
session.commit()
|
||||
|
||||
# Инвалидируем кеш только для этой конкретной темы
|
||||
await invalidate_topics_cache(topic.id)
|
||||
|
||||
# Если slug изменился, удаляем старый ключ
|
||||
if old_slug != topic.slug:
|
||||
await redis.execute("DEL", f"topic:slug:{old_slug}")
|
||||
logger.debug(f"Удален ключ кеша для старого slug: {old_slug}")
|
||||
|
||||
return {"topic": topic}
|
||||
|
||||
|
||||
@@ -111,6 +302,11 @@ async def delete_topic(_, info, slug: str):
|
||||
session.delete(t)
|
||||
session.commit()
|
||||
|
||||
# Инвалидируем кеш всех тем и конкретной темы
|
||||
await invalidate_topics_cache()
|
||||
await redis.execute("DEL", f"topic:slug:{slug}")
|
||||
await redis.execute("DEL", f"topic:id:{t.id}")
|
||||
|
||||
return {}
|
||||
return {"error": "access denied"}
|
||||
|
||||
|
Reference in New Issue
Block a user