docs-restruct

This commit is contained in:
2025-10-02 01:16:14 +03:00
parent 31cf6b6961
commit 4038c5dbf5
3 changed files with 190 additions and 19 deletions

View File

@@ -1,5 +1,15 @@
# Changelog
## [0.9.29] - 2025-10-01
### 🔧 Fixed
- **Фичерение публикаций**: Исправлена логика автоматического фичерения/расфичерения
- Теперь учитываются все положительные реакции (LIKE, ACCEPT, PROOF), а не только LIKE
- Исправлен подсчет реакций в `check_to_unfeature`: используется POSITIVE + NEGATIVE вместо только RATING_REACTIONS
- Добавлена явная проверка `reply_to.is_(None)` для исключения комментариев
- **Ревалидация кеша**: Добавлена ревалидация кеша публикаций, авторов и тем при изменении `featured_at`
- Улучшено логирование для отладки процесса фичерения
## [0.9.28] - 2025-09-28
### 🍪 CRITICAL Cross-Origin Auth

View File

@@ -25,8 +25,36 @@ from utils.logger import root_logger as logger
def get_entity_field_name(entity_type: str) -> str:
"""Возвращает имя поля для связи с сущностью в модели подписчика"""
entity_field_mapping = {"author": "following", "topic": "topic", "community": "community", "shout": "shout"}
"""
Возвращает имя поля для связи с сущностью в модели подписчика.
Эта функция используется для определения правильного поля в моделях подписчиков
(AuthorFollower, TopicFollower, CommunityFollower, ShoutReactionsFollower) при создании
или проверке подписки.
Args:
entity_type: Тип сущности в нижнем регистре ('author', 'topic', 'community', 'shout')
Returns:
str: Имя поля в модели подписчика ('following', 'topic', 'community', 'shout')
Raises:
ValueError: Если передан неизвестный тип сущности
Examples:
>>> get_entity_field_name('author')
'following'
>>> get_entity_field_name('topic')
'topic'
>>> get_entity_field_name('invalid')
ValueError: Unknown entity_type: invalid
"""
entity_field_mapping = {
"author": "following", # AuthorFollower.following -> Author
"topic": "topic", # TopicFollower.topic -> Topic
"community": "community", # CommunityFollower.community -> Community
"shout": "shout", # ShoutReactionsFollower.shout -> Shout
}
if entity_type not in entity_field_mapping:
msg = f"Unknown entity_type: {entity_type}"
raise ValueError(msg)
@@ -38,6 +66,36 @@ def get_entity_field_name(entity_type: str) -> str:
async def follow(
_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None
) -> dict[str, Any]:
"""
GraphQL мутация для создания подписки на автора, тему, сообщество или публикацию.
Эта функция обрабатывает все типы подписок в системе, включая:
- Подписку на автора (AUTHOR)
- Подписку на тему (TOPIC)
- Подписку на сообщество (COMMUNITY)
- Подписку на публикацию (SHOUT)
Args:
_: None - Стандартный параметр GraphQL (не используется)
info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе
what: str - Тип сущности для подписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT')
slug: str - Slug сущности (например, 'author-slug' или 'topic-slug')
entity_id: int | None - ID сущности (альтернатива slug)
Returns:
dict[str, Any] - Результат операции:
{
"success": bool, # Успешность операции
"error": str | None, # Текст ошибки если есть
"authors": Author[], # Обновленные авторы (для кеширования)
"topics": Topic[], # Обновленные темы (для кеширования)
"entity_id": int | None # ID созданной подписки
}
Raises:
ValueError: При передаче некорректных параметров
DatabaseError: При проблемах с базой данных
"""
logger.debug("Начало выполнения функции 'follow'")
viewer_id = info.context.get("author", {}).get("id")
follower_dict = info.context.get("author") or {}
@@ -51,7 +109,9 @@ async def follow(
await redis.execute("DEL", f"author:id:{viewer_id}")
logger.debug(f"Инвалидирован кеш подписок follower'а: {cache_key_pattern}")
# Проверка авторизации пользователя
if not viewer_id:
logger.warning("Попытка подписаться без авторизации")
return {"error": "Access denied"}
logger.debug(f"follower: {follower_dict}")
@@ -63,6 +123,7 @@ async def follow(
follower_id = follower_dict.get("id")
logger.debug(f"follower_id: {follower_id}")
# Маппинг типов сущностей на их классы и методы кеширования
entity_classes = {
"AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author),
"TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic),
@@ -207,11 +268,81 @@ async def follow(
async def unfollow(
_: None, info: GraphQLResolveInfo, what: str, slug: str = "", entity_id: int | None = None
) -> dict[str, Any]:
"""
GraphQL мутация для отмены подписки на автора, тему, сообщество или публикацию.
Эта функция обрабатывает отмену всех типов подписок в системе, включая:
- Отписку от автора (AUTHOR)
- Отписку от темы (TOPIC)
- Отписку от сообщества (COMMUNITY)
- Отписку от публикации (SHOUT)
Процесс отмены подписки:
1. Проверка авторизации пользователя
2. Поиск существующей подписки в базе данных
3. Удаление подписки если она найдена
4. Инвалидация кеша для обновления данных
5. Отправка уведомлений об отписке
Args:
_: None - Стандартный параметр GraphQL (не используется)
info: GraphQLResolveInfo - Контекст GraphQL запроса, содержит информацию об авторизованном пользователе
what: str - Тип сущности для отписки ('AUTHOR', 'TOPIC', 'COMMUNITY', 'SHOUT')
slug: str - Slug сущности (например, 'author-slug' или 'topic-slug')
entity_id: int | None - ID сущности (альтернатива slug)
Returns:
dict[str, Any] - Результат операции:
{
"success": bool, # Успешность операции
"error": str | None, # Текст ошибки если есть
"authors": Author[], # Обновленные авторы (для кеширования)
"topics": Topic[], # Обновленные темы (для кеширования)
}
Raises:
ValueError: При передаче некорректных параметров
DatabaseError: При проблемах с базой данных
Examples:
# Отписка от автора
mutation {
unfollow(what: "AUTHOR", slug: "author-slug") {
success
error
}
}
# Отписка от темы
mutation {
unfollow(what: "TOPIC", slug: "topic-slug") {
success
error
}
}
# Отписка от сообщества
mutation {
unfollow(what: "COMMUNITY", slug: "community-slug") {
success
error
}
}
# Отписка от публикации
mutation {
unfollow(what: "SHOUT", entity_id: 123) {
success
error
}
}
"""
logger.debug("Начало выполнения функции 'unfollow'")
viewer_id = info.context.get("author", {}).get("id")
follower_dict = info.context.get("author") or {}
# ✅ КРИТИЧНО: Инвалидируем кеш В САМОМ НАЧАЛЕ, если пользователь авторизован
# чтобы предотвратить чтение старых данных при последующей перезагрузке
if viewer_id:
entity_type = what.lower()
cache_key_pattern = f"author:follows-{entity_type}s:{viewer_id}"
@@ -219,7 +350,9 @@ async def unfollow(
await redis.execute("DEL", f"author:id:{viewer_id}")
logger.debug(f"Инвалидирован кеш подписок В НАЧАЛЕ операции unfollow: {cache_key_pattern}")
# Проверка авторизации пользователя
if not viewer_id:
logger.warning("Попытка отписаться без авторизации")
return {"error": "Access denied"}
logger.debug(f"follower: {follower_dict}")
@@ -231,6 +364,7 @@ async def unfollow(
follower_id = follower_dict.get("id")
logger.debug(f"follower_id: {follower_id}")
# Маппинг типов сущностей на их классы и методы кеширования
entity_classes = {
"AUTHOR": (Author, AuthorFollower, get_cached_follower_authors, cache_author),
"TOPIC": (Topic, TopicFollower, get_cached_follower_topics, cache_topic),

View File

@@ -143,27 +143,29 @@ def is_featured_author(session: Session, author_id: int) -> bool:
def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool:
"""
Make a shout featured if it receives more than 4 votes from authors.
Make a shout featured if it receives more than 4 votes from featured authors.
:param session: Database session.
:param approver_id: Approver author ID.
:param reaction: Reaction object.
:return: True if shout should be featured, else False.
"""
is_positive_kind = reaction.get("kind") == ReactionKind.LIKE.value
# 🔧 Проверяем любую положительную реакцию (LIKE, ACCEPT, PROOF), не только LIKE
is_positive_kind = reaction.get("kind") in POSITIVE_REACTIONS
if not reaction.get("reply_to") and is_positive_kind:
# Проверяем, не содержит ли пост более 20% дизлайков
# Если да, то не должен быть featured независимо от количества лайков
if check_to_unfeature(session, reaction):
return False
# Собираем всех авторов, поставивших лайк
# Собираем всех авторов, поставивших положительную реакцию
author_approvers = set()
reacted_readers = (
session.query(Reaction.created_by)
.where(
Reaction.shout == reaction.get("shout"),
Reaction.kind.in_(POSITIVE_REACTIONS),
Reaction.reply_to.is_(None), # не реакция на комментарий
# Рейтинги (LIKE, DISLIKE) физически удаляются, поэтому фильтр deleted_at не нужен
)
.distinct()
@@ -189,7 +191,7 @@ def check_to_feature(session: Session, approver_id: int, reaction: dict) -> bool
def check_to_unfeature(session: Session, reaction: dict) -> bool:
"""
Unfeature a shout if:
1. Less than 5 positive votes, OR
1. Less than 5 positive votes from featured authors, OR
2. 20% or more of reactions are negative.
:param session: Database session.
@@ -199,18 +201,8 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
if not reaction.get("reply_to"):
shout_id = reaction.get("shout")
# Проверяем соотношение дизлайков, даже если текущая реакция не дизлайк
total_reactions = (
session.query(Reaction)
.where(
Reaction.shout == shout_id,
Reaction.reply_to.is_(None),
Reaction.kind.in_(RATING_REACTIONS),
# Рейтинги физически удаляются при удалении, поэтому фильтр deleted_at не нужен
)
.count()
)
# 🔧 Считаем все рейтинговые реакции (положительные + отрицательные)
# Используем POSITIVE_REACTIONS + NEGATIVE_REACTIONS вместо только RATING_REACTIONS
positive_reactions = (
session.query(Reaction)
.where(
@@ -233,9 +225,13 @@ def check_to_unfeature(session: Session, reaction: dict) -> bool:
.count()
)
total_reactions = positive_reactions + negative_reactions
# Условие 1: Меньше 5 голосов "за"
if positive_reactions < 5:
logger.debug(f"Публикация {shout_id}: {positive_reactions} лайков (меньше 5) - должна быть unfeatured")
logger.debug(
f"Публикация {shout_id}: {positive_reactions} положительных реакций (меньше 5) - должна быть unfeatured"
)
return True
# Условие 2: Проверяем, составляют ли отрицательные реакции 20% или более от всех реакций
@@ -256,6 +252,8 @@ async def set_featured(session: Session, shout_id: int) -> None:
:param session: Database session.
:param shout_id: Shout ID.
"""
from cache.revalidator import revalidation_manager
s = session.query(Shout).where(Shout.id == shout_id).first()
if s:
current_time = int(time.time())
@@ -267,6 +265,17 @@ async def set_featured(session: Session, shout_id: int) -> None:
session.add(s)
session.commit()
# 🔧 Ревалидация кеша публикации и связанных сущностей
revalidation_manager.mark_for_revalidation(shout_id, "shouts")
# Ревалидируем авторов публикации
for author in s.authors:
revalidation_manager.mark_for_revalidation(author.id, "authors")
# Ревалидируем темы публикации
for topic in s.topics:
revalidation_manager.mark_for_revalidation(topic.id, "topics")
logger.info(f"Публикация {shout_id} получила статус featured, кеш помечен для ревалидации")
def set_unfeatured(session: Session, shout_id: int) -> None:
"""
@@ -275,9 +284,27 @@ def set_unfeatured(session: Session, shout_id: int) -> None:
:param session: Database session.
:param shout_id: Shout ID.
"""
from cache.revalidator import revalidation_manager
# Получаем публикацию для доступа к авторам и темам
shout = session.query(Shout).where(Shout.id == shout_id).first()
if not shout:
return
session.query(Shout).where(Shout.id == shout_id).update({"featured_at": None})
session.commit()
# 🔧 Ревалидация кеша публикации и связанных сущностей
revalidation_manager.mark_for_revalidation(shout_id, "shouts")
# Ревалидируем авторов публикации
for author in shout.authors:
revalidation_manager.mark_for_revalidation(author.id, "authors")
# Ревалидируем темы публикации
for topic in shout.topics:
revalidation_manager.mark_for_revalidation(topic.id, "topics")
logger.info(f"Публикация {shout_id} потеряла статус featured, кеш помечен для ревалидации")
async def _create_reaction(session: Session, shout_id: int, is_author: bool, author_id: int, reaction: dict) -> dict:
"""