docs-restruct
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user