reworked-feed+reader
All checks were successful
Deploy on push / deploy (push) Successful in 1m16s

This commit is contained in:
Untone 2024-11-01 13:50:47 +03:00
parent a01a3f1d7a
commit d88f905609
3 changed files with 177 additions and 249 deletions

View File

@ -1,12 +1,12 @@
from operator import and_ from operator import and_
from graphql import GraphQLError from graphql import GraphQLError
from sqlalchemy import delete, insert, select from sqlalchemy import delete, insert
from orm.author import AuthorBookmark from orm.author import AuthorBookmark
from orm.shout import Shout from orm.shout import Shout
from resolvers.feed import apply_options from resolvers.feed import apply_options
from resolvers.reader import get_shouts_with_links, has_field, query_with_stat from resolvers.reader import get_shouts_with_links, query_with_stat
from services.auth import login_required from services.auth import login_required
from services.common_result import CommonResult from services.common_result import CommonResult
from services.db import local_session from services.db import local_session
@ -31,11 +31,7 @@ def load_shouts_bookmarked(_, info, options):
if not author_id: if not author_id:
raise GraphQLError("User not authenticated") raise GraphQLError("User not authenticated")
q = ( q = query_with_stat(info)
query_with_stat()
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.join(AuthorBookmark) q = q.join(AuthorBookmark)
q = q.filter( q = q.filter(
and_( and_(

View File

@ -1,7 +1,6 @@
from typing import List from typing import List
from sqlalchemy import and_, desc, select, text, union from sqlalchemy import and_, select
from sqlalchemy.orm import joinedload
from orm.author import Author, AuthorFollower from orm.author import Author, AuthorFollower
from orm.reaction import Reaction from orm.reaction import Reaction
@ -14,62 +13,36 @@ from services.schema import query
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
def apply_options(q, options, author_id: int): def apply_options(q, options, reactions_created_by=0):
""" """
Применяет опции фильтрации и сортировки к запросу для данного автора. Применяет опции фильтрации и сортировки
[опционально] выбирая те публикации, на которые есть реакции от указанного автора
:param q: Исходный запрос. :param q: Исходный запрос.
:param options: Опции фильтрации и сортировки. :param options: Опции фильтрации и сортировки.
:param author_id: Идентификатор автора. :param reactions_created_by: Идентификатор автора.
:return: Запрос с примененными опциями. :return: Запрос с примененными опциями.
""" """
filters = options.get("filters") filters = options.get("filters")
if isinstance(filters, dict): if isinstance(filters, dict):
q = apply_filters(q, filters) q = apply_filters(q, filters)
if author_id and "reacted" in filters: if reactions_created_by:
reacted = filters.get("reacted") if "reacted" in filters or "commented" in filters:
q = q.join(Reaction, Reaction.shout == Shout.id) commented = filters.get("commented")
if reacted: reacted = filters.get("reacted") or commented
q = q.filter(Reaction.created_by == author_id) q = q.join(Reaction, Reaction.shout == Shout.id)
else: if commented:
q = q.filter(Reaction.created_by != author_id) q = q.filter(Reaction.body.is_not(None))
if reacted:
q = q.filter(Reaction.created_by == reactions_created_by)
else:
q = q.filter(Reaction.created_by != reactions_created_by)
q = apply_sorting(q, options) q = apply_sorting(q, options)
limit = options.get("limit", 10) limit = options.get("limit", 10)
offset = options.get("offset", 0) offset = options.get("offset", 0)
return q, limit, offset return q, limit, offset
def filter_followed(info, q):
"""
Фильтрация публикаций, основанная на подписках пользователя.
:param info: Информация о контексте GraphQL.
:param q: Исходный запрос для публикаций.
:return: Фильтрованный запрос.
"""
user_id = info.context.get("user_id")
reader_id = info.context.get("author", {}).get("id")
if user_id and reader_id:
reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == reader_id)
reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == reader_id)
reader_followed_shouts = select(ShoutReactionsFollower.shout).where(
ShoutReactionsFollower.follower == reader_id
)
subquery = (
select(Shout.id)
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.join(ShoutTopic, ShoutTopic.shout == Shout.id)
.where(
ShoutAuthor.author.in_(reader_followed_authors)
| ShoutTopic.topic.in_(reader_followed_topics)
| Shout.id.in_(reader_followed_shouts)
)
)
q = q.filter(Shout.id.in_(subquery))
return q, reader_id
@query.field("load_shouts_coauthored") @query.field("load_shouts_coauthored")
@login_required @login_required
async def load_shouts_coauthored(_, info, options): async def load_shouts_coauthored(_, info, options):
@ -83,27 +56,9 @@ async def load_shouts_coauthored(_, info, options):
author_id = info.context.get("author", {}).get("id") author_id = info.context.get("author", {}).get("id")
if not author_id: if not author_id:
return [] return []
q = ( q = query_with_stat(info)
query_with_stat()
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.filter(Shout.authors.any(id=author_id)) q = q.filter(Shout.authors.any(id=author_id))
q, limit, offset = apply_options(q, options)
filters = options.get("filters")
if isinstance(filters, dict):
q = apply_filters(q, filters)
if filters.get("reacted"):
q = q.join(
Reaction,
and_(
Reaction.shout == Shout.id,
Reaction.created_by == author_id,
),
)
q = apply_sorting(q, options)
limit = options.get("limit", 10)
offset = options.get("offset", 0)
return get_shouts_with_links(info, q, limit, offset=offset) return get_shouts_with_links(info, q, limit, offset=offset)
@ -120,19 +75,8 @@ async def load_shouts_discussed(_, info, options):
author_id = info.context.get("author", {}).get("id") author_id = info.context.get("author", {}).get("id")
if not author_id: if not author_id:
return [] return []
# Подзапрос для поиска идентификаторов публикаций, которые комментировал автор q = query_with_stat(info)
reaction_subquery = ( options["filters"]["commented"] = True
select(Reaction.shout)
.distinct() # Убедитесь, что получены уникальные идентификаторы публикаций
.filter(and_(Reaction.created_by == author_id, Reaction.body.is_not(None)))
.correlate(Shout) # Убедитесь, что подзапрос правильно связан с основным запросом
)
q = (
query_with_stat()
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q = q.filter(Shout.id.in_(reaction_subquery))
q, limit, offset = apply_options(q, options, author_id) q, limit, offset = apply_options(q, options, author_id)
return get_shouts_with_links(info, q, limit, offset=offset) return get_shouts_with_links(info, q, limit, offset=offset)
@ -141,33 +85,32 @@ def shouts_by_follower(info, follower_id: int, options):
""" """
Загружает публикации, на которые подписан автор. Загружает публикации, на которые подписан автор.
- по авторам
- по темам
- по реакциям
:param info: Информация о контексте GraphQL. :param info: Информация о контексте GraphQL.
:param follower_id: Идентификатор автора. :param follower_id: Идентификатор автора.
:param options: Опции фильтрации и сортировки. :param options: Опции фильтрации и сортировки.
:return: Список публикаций. :return: Список публикаций.
""" """
# Публикации, где подписчик является автором q = query_with_stat(info)
q1 = ( reader_followed_authors = select(AuthorFollower.author).where(AuthorFollower.follower == follower_id)
query_with_stat() reader_followed_topics = select(TopicFollower.topic).where(TopicFollower.follower == follower_id)
if has_field(info, "stat") reader_followed_shouts = select(ShoutReactionsFollower.shout).where(ShoutReactionsFollower.follower == follower_id)
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
followed_subquery = (
select(Shout.id)
.join(ShoutAuthor, ShoutAuthor.shout == Shout.id)
.join(ShoutTopic, ShoutTopic.shout == Shout.id)
.where(
ShoutAuthor.author.in_(reader_followed_authors)
| ShoutTopic.topic.in_(reader_followed_topics)
| Shout.id.in_(reader_followed_shouts)
)
) )
q1 = q1.filter(Shout.authors.any(id=follower_id)) q = q.filter(Shout.id.in_(followed_subquery))
q, limit, offset = apply_options(q, options)
# Публикации, на которые подписчик реагировал
q2 = (
query_with_stat()
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
q2 = q2.options(joinedload(Shout.reactions))
q2 = q2.filter(Reaction.created_by == follower_id)
# Сортировка публикаций по полю `last_reacted_at`
combined_query = union(q1, q2).order_by(desc(text("last_reacted_at")))
# извлечение ожидаемой структуры данных
q, limit, offset = apply_options(combined_query, options, follower_id)
shouts = get_shouts_with_links(info, q, limit, offset=offset) shouts = get_shouts_with_links(info, q, limit, offset=offset)
return shouts return shouts
@ -216,7 +159,7 @@ async def load_shouts_authored_by(_, info, slug: str, options) -> List[Shout]:
try: try:
author_id: int = author.dict()["id"] author_id: int = author.dict()["id"]
q = ( q = (
query_with_stat() query_with_stat(info)
if has_field(info, "stat") if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
) )
@ -240,7 +183,7 @@ async def load_shouts_with_topic(_, info, slug: str, options) -> List[Shout]:
try: try:
topic_id: int = topic.dict()["id"] topic_id: int = topic.dict()["id"]
q = ( q = (
query_with_stat() query_with_stat(info)
if has_field(info, "stat") if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
) )

View File

@ -29,140 +29,160 @@ def has_field(info, fieldname: str) -> bool:
return False return False
def query_with_stat(): def query_with_stat(info):
""" """
добавляет подзапрос статистики Добавляет подзапрос статистики
:param info: Информация о контексте GraphQL
:return: Запрос с подзапросом статистики. :return: Запрос с подзапросом статистики.
""" """
stats_subquery = (
select( q = select(Shout)
Reaction.shout.label("shout_id"),
func.count(case((Reaction.kind == ReactionKind.COMMENT.value, 1), else_=None)).label("comments_count"), if has_field(info, "stat"):
func.sum( stats_subquery = (
case( select(
(Reaction.kind == ReactionKind.LIKE.value, 1), Reaction.shout,
(Reaction.kind == ReactionKind.DISLIKE.value, -1), func.count(case([(Reaction.kind == ReactionKind.COMMENT.value, 1)], else_=None)).label(
else_=0, "comments_count"
) ),
).label("rating"), func.sum(
func.max(case((Reaction.reply_to.is_(None), Reaction.created_at), else_=None)).label("last_reacted_at"), case(
[
(Reaction.kind == ReactionKind.LIKE.value, 1),
(Reaction.kind == ReactionKind.DISLIKE.value, -1),
],
else_=0,
)
).label("rating"),
func.max(case([(Reaction.reply_to.is_(None), Reaction.created_at)], else_=None)).label(
"last_reacted_at"
),
)
.where(Reaction.deleted_at.is_(None))
.group_by(Reaction.shout)
.subquery()
) )
.where(Reaction.deleted_at.is_(None))
.group_by(Reaction.shout)
.subquery()
)
q = ( q = q.outerjoin(stats_subquery, stats_subquery.c.shout == Shout.id)
select(Shout, stats_subquery) q = q.add_columns(stats_subquery.c.comments_count, stats_subquery.c.rating, stats_subquery.c.last_reacted_at)
.outerjoin(stats_subquery, stats_subquery.c.shout_id == Shout.id)
.where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
return q
def get_shouts_with_links(info, q, limit=20, offset=0, author_id=None):
"""
Оптимизированное получение публикаций с минимизацией количества запросов.
"""
if author_id:
q = q.filter(Shout.created_by == author_id)
if limit:
q = q.limit(limit)
if offset:
q = q.offset(offset)
# Предварительно определяем флаги для запрашиваемых полей
includes_authors = has_field(info, "authors")
includes_topics = has_field(info, "topics")
includes_stat = has_field(info, "stat")
includes_media = has_field(info, "media")
# created_by и main_topic
if has_field(info, "created_by"): if has_field(info, "created_by"):
q = q.outerjoin(Author, Shout.created_by == Author.id).add_columns( q = q.outerjoin(Author, Shout.created_by == Author.id)
q = q.add_columns(
Author.id.label("main_author_id"), Author.id.label("main_author_id"),
Author.name.label("main_author_name"), Author.name.label("main_author_name"),
Author.slug.label("main_author_slug"), Author.slug.label("main_author_slug"),
Author.pic.label("main_author_pic"), Author.pic.label("main_author_pic"),
# Author.caption.label("main_author_caption"),
) )
if has_field(info, "main_topic"): if has_field(info, "main_topic"):
q = q.outerjoin(ShoutTopic, and_(ShoutTopic.shout == Shout.id, ShoutTopic.main.is_(True))) q = q.outerjoin(ShoutTopic, and_(ShoutTopic.shout == Shout.id, ShoutTopic.main.is_(True)))
q = q.outerjoin(Topic, ShoutTopic.topic == Topic.id) q = q.outerjoin(Topic, ShoutTopic.topic == Topic.id)
q = q.add_columns( q = q.add_columns(
Topic.id.label("main_topic_id"), Topic.id.label("main_topic_id"), Topic.title.label("main_topic_title"), Topic.slug.label("main_topic_slug")
Topic.title.label("main_topic_title"),
Topic.slug.label("main_topic_slug"),
# func.literal(True).label("main_topic_is_main"),
) )
if has_field(info, "authors"):
topics_subquery = (
select(
ShoutTopic.shout,
Topic.id.label("topic_id"),
Topic.title.label("topic_title"),
Topic.slug.label("topic_slug"),
ShoutTopic.main.label("is_main"),
)
.outerjoin(Topic, ShoutTopic.topic == Topic.id)
.where(ShoutTopic.shout == Shout.id)
.subquery()
)
q = q.outerjoin(topics_subquery, topics_subquery.c.shout == Shout.id)
q = q.add_columns(
topics_subquery.c.topic_id,
topics_subquery.c.topic_title,
topics_subquery.c.topic_slug,
topics_subquery.c.is_main,
)
authors_subquery = (
select(
ShoutAuthor.shout,
Author.id.label("author_id"),
Author.name.label("author_name"),
Author.slug.label("author_slug"),
Author.pic.label("author_pic"),
ShoutAuthor.caption.label("author_caption"),
)
.outerjoin(Author, ShoutAuthor.author == Author.id)
.where(ShoutAuthor.shout == Shout.id)
.subquery()
)
q = q.outerjoin(authors_subquery, authors_subquery.c.shout == Shout.id)
q = q.add_columns(
authors_subquery.c.author_id,
authors_subquery.c.author_name,
authors_subquery.c.author_slug,
authors_subquery.c.author_pic,
authors_subquery.c.author_caption,
)
# Фильтр опубликованных
q = q.where(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
return q
def get_shouts_with_links(info, q, limit=20, offset=0):
"""
получение публикаций с применением пагинации
"""
q = q.limit(limit).offset(offset)
includes_authors = has_field(info, "authors")
includes_topics = has_field(info, "topics")
includes_stat = has_field(info, "stat")
includes_media = has_field(info, "media")
with local_session() as session: with local_session() as session:
shouts_result = session.execute(q).all() shouts_result = session.execute(q).all()
if not shouts_result: if not shouts_result:
return [] return []
shout_ids = [shout.Shout.id for shout in shouts_result] shouts_result = []
authors_and_topics = []
if includes_authors or includes_topics:
query = (
select(
ShoutAuthor.shout.label("shout_id"),
Author.id.label("author_id"),
Author.name.label("author_name"),
Author.slug.label("author_slug"),
Author.pic.label("author_pic"),
ShoutAuthor.caption.label("author_caption"),
Topic.id.label("topic_id"),
Topic.title.label("topic_title"),
Topic.slug.label("topic_slug"),
ShoutTopic.main.label("topic_is_main"),
)
.outerjoin(Author, ShoutAuthor.author == Author.id)
.outerjoin(ShoutTopic, ShoutTopic.shout == ShoutAuthor.shout)
.outerjoin(Topic, ShoutTopic.topic == Topic.id)
.where(ShoutAuthor.shout.in_(shout_ids))
)
authors_and_topics = session.execute(query).all()
# Создаем словарь для хранения данных публикаций
shouts_data = {}
for row in shouts_result: for row in shouts_result:
logger.debug(row)
shout_id = row.Shout.id
shout_dict = row.Shout.dict() shout_dict = row.Shout.dict()
shout_dict["authors"] = [] shout_dict.update(
shout_dict["topics"] = set() {
"authors": [],
# Добавляем данные main_author_, если они были запрошены "topics": set(),
if has_field(info, "created_by"): "media": json.dumps(shout_dict.get("media", [])) if includes_media else [],
main_author = { "stat": {
"viewed": ViewedStorage.get_shout(shout_id=shout_id) or 0,
"commented": row.comments_count or 0,
"rating": row.rating or 0,
"last_reacted_at": row.last_reacted_at,
}
if includes_stat and hasattr(row, "comments_count")
else {},
}
)
if includes_authors and hasattr(row, "main_author_id"):
shout_dict["created_by"] = {
"id": row.main_author_id, "id": row.main_author_id,
"name": row.main_author_name or "Аноним", "name": row.main_author_name or "Аноним",
"slug": row.main_author_slug or "", "slug": row.main_author_slug or "",
"pic": row.main_author_pic or "", "pic": row.main_author_pic or "",
"caption": row.main_author_caption or "",
} }
shout_dict["created_by"] = main_author if includes_topics and hasattr(row, "main_topic_id"):
shout_dict["main_topic"] = {
# Добавляем данные main_topic, если они были запрошены
if has_field(info, "main_topic"):
main_topic = {
"id": row.main_topic_id or 0, "id": row.main_topic_id or 0,
"title": row.main_topic_title or "", "title": row.main_topic_title or "",
"slug": row.main_topic_slug or "", "slug": row.main_topic_slug or "",
# "is_main": True,
} }
shout_dict["main_topic"] = main_topic
shouts_data[row.id] = shout_dict if includes_authors and hasattr(row, "author_id"):
# Обрабатываем данные authors и topics из дополнительного запроса
for row in authors_and_topics:
shout_data = shouts_data.get(row.shout_id)
if not shout_data:
continue # Пропускаем, если shout не найден
if includes_authors:
author = { author = {
"id": row.author_id, "id": row.author_id,
"name": row.author_name, "name": row.author_name,
@ -170,52 +190,25 @@ def get_shouts_with_links(info, q, limit=20, offset=0, author_id=None):
"pic": row.author_pic, "pic": row.author_pic,
"caption": row.author_caption, "caption": row.author_caption,
} }
if author not in shout_data["authors"]: if not filter(lambda x: x["id"] == author["id"], shout_dict["authors"]):
shout_data["authors"].append(author) shout_dict["authors"].append(author)
if includes_topics and row.topic_id: if includes_topics and hasattr(row, "topic_id"):
topic = { topic = {
"id": row.topic_id, "id": row.topic_id,
"title": row.topic_title, "title": row.topic_title,
"slug": row.topic_slug, "slug": row.topic_slug,
"is_main": row.topic_is_main, "is_main": row.is_main,
} }
shout_data["topics"].add(tuple(topic.items())) if not filter(lambda x: x["id"] == topic["id"], shout_dict["topics"]):
shout_dict["topics"].add(frozenset(topic.items()))
# Обрабатываем дополнительные поля и гарантируем наличие main_topic if includes_topics:
for shout in shouts_data.values(): shout_dict["topics"] = sorted(
if includes_media: [dict(t) for t in shout_dict["topics"]], key=lambda x: (not x.get("is_main", False), x["id"])
shout["media"] = json.dumps(shout.get("media", []))
if includes_stat:
shout_id = shout["id"]
viewed_stat = ViewedStorage.get_shout(shout_id=shout_id) or 0
shout["stat"] = {
"viewed": viewed_stat,
"commented": shout.get("comments_count", 0),
"rating": shout.get("rating", 0),
"last_reacted_at": shout.get("last_reacted_at"),
}
# Гарантируем наличие main_topic, если оно не запрашивалось
if not has_field(info, "main_topic"):
if "main_topic" not in shout or not shout["main_topic"]:
logger.error(f"Shout ID {shout['id']} не имеет основной темы.")
shout["main_topic"] = {
"id": 0,
"title": "Основная тема",
"slug": "",
# "is_main": True,
}
# Сортировка topics, если они есть
if shout["topics"]:
shout["topics"] = sorted(
[dict(t) for t in shout["topics"]], key=lambda x: (not x.get("is_main", False), x["id"])
) )
else:
shout["topics"] = []
return list(shouts_data.values()) return shouts_result
def apply_filters(q, filters): def apply_filters(q, filters):
@ -263,7 +256,7 @@ async def get_shout(_, info, slug="", shout_id=0):
""" """
try: try:
# Получаем базовый запрос с подзапросами статистики # Получаем базовый запрос с подзапросами статистики
q = query_with_stat() q = query_with_stat(info)
# Применяем фильтр по slug или id # Применяем фильтр по slug или id
if slug: if slug:
@ -319,11 +312,7 @@ async def load_shouts_by(_, info, options):
:return: Список публикаций, удовлетворяющих критериям. :return: Список публикаций, удовлетворяющих критериям.
""" """
# Базовый запрос: если запрашиваются статистические данные, используем специальный запрос с статистикой # Базовый запрос: если запрашиваются статистические данные, используем специальный запрос с статистикой
q = ( q = query_with_stat(info)
query_with_stat()
if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
)
filters = options.get("filters") filters = options.get("filters")
if isinstance(filters, dict): if isinstance(filters, dict):
@ -364,7 +353,7 @@ async def load_shouts_search(_, info, text, options):
hits_ids.append(shout_id) hits_ids.append(shout_id)
q = ( q = (
query_with_stat() query_with_stat(info)
if has_field(info, "stat") if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
) )
@ -447,7 +436,7 @@ async def load_shouts_random_top(_, info, options):
if random_limit: if random_limit:
subquery = subquery.limit(random_limit) subquery = subquery.limit(random_limit)
q = ( q = (
query_with_stat() query_with_stat(info)
if has_field(info, "stat") if has_field(info, "stat")
else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None))) else select(Shout).filter(and_(Shout.published_at.is_not(None), Shout.deleted_at.is_(None)))
) )