From 5024e963e3944cf6d7937442a53cde63f258a01f Mon Sep 17 00:00:00 2001 From: Untone Date: Thu, 24 Apr 2025 12:12:48 +0300 Subject: [PATCH 01/36] notify-draft-hotfix --- services/notify.py | 63 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/services/notify.py b/services/notify.py index 911bd6ec..91896f68 100644 --- a/services/notify.py +++ b/services/notify.py @@ -53,3 +53,66 @@ async def notify_follower(follower: dict, author_id: int, action: str = "follow" except Exception as e: # Log the error and re-raise it logger.error(f"Failed to publish to channel {channel_name}: {e}") + + +async def notify_draft(draft_data, action: str = "publish"): + """ + Отправляет уведомление о публикации или обновлении черновика. + + Функция гарантирует, что данные черновика сериализуются корректно, включая + связанные атрибуты (topics, authors). + + Args: + draft_data (dict): Словарь с данными черновика. Должен содержать минимум id и title + action (str, optional): Действие ("publish", "update"). По умолчанию "publish" + + Returns: + None + + Examples: + >>> draft = {"id": 1, "title": "Тестовый черновик", "slug": "test-draft"} + >>> await notify_draft(draft, "publish") + """ + channel_name = "draft" + try: + # Убеждаемся, что все необходимые данные присутствуют + # и объект не требует доступа к отсоединенным атрибутам + if isinstance(draft_data, dict): + draft_payload = draft_data + else: + # Если это ORM объект, преобразуем его в словарь с нужными атрибутами + draft_payload = { + "id": getattr(draft_data, "id", None), + "slug": getattr(draft_data, "slug", None), + "title": getattr(draft_data, "title", None), + "subtitle": getattr(draft_data, "subtitle", None), + "media": getattr(draft_data, "media", None), + "created_at": getattr(draft_data, "created_at", None), + "updated_at": getattr(draft_data, "updated_at", None) + } + + # Если переданы связанные атрибуты, добавим их + if hasattr(draft_data, "topics") and draft_data.topics is not None: + draft_payload["topics"] = [ + {"id": t.id, "name": t.name, "slug": t.slug} + for t in draft_data.topics + ] + + if hasattr(draft_data, "authors") and draft_data.authors is not None: + draft_payload["authors"] = [ + {"id": a.id, "name": a.name, "slug": a.slug, "pic": getattr(a, "pic", None)} + for a in draft_data.authors + ] + + data = {"payload": draft_payload, "action": action} + + # Сохраняем уведомление + save_notification(action, channel_name, data.get("payload")) + + # Публикуем в Redis + json_data = orjson.dumps(data) + if json_data: + await redis.publish(channel_name, json_data) + + except Exception as e: + logger.error(f"Failed to publish to channel {channel_name}: {e}") From 6b2ac09f74a4b8a1e246dc3a6b2bc83e14240698 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 10:16:55 +0300 Subject: [PATCH 02/36] unpublish-fix --- resolvers/draft.py | 111 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 4c721c52..4296e948 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -3,6 +3,7 @@ from operator import or_ import trafilatura from sqlalchemy.sql import and_ +from sqlalchemy.orm import joinedload from cache.cache import ( cache_author, @@ -17,7 +18,7 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from services.auth import login_required from services.db import local_session -from services.notify import notify_shout +from services.notify import notify_shout, notify_draft from services.schema import mutation, query from services.search import search_service from utils.logger import root_logger as logger @@ -234,6 +235,17 @@ async def delete_draft(_, info, draft_id: int): @mutation.field("publish_draft") @login_required async def publish_draft(_, info, draft_id: int): + """Публикует черновик в виде публикации (shout). + + Загружает связанные объекты (topics, authors) заранее, чтобы избежать ошибок + с отсоединенными объектами при сериализации. + + Args: + draft_id: ID черновика для публикации + + Returns: + dict: Опубликованная публикация и черновик или сообщение об ошибке + """ user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") @@ -241,18 +253,78 @@ async def publish_draft(_, info, draft_id: int): return {"error": "User ID and author ID are required"} with local_session() as session: - draft = session.query(Draft).filter(Draft.id == draft_id).first() + # Загружаем черновик со связанными объектами (topics, authors) + draft = ( + session.query(Draft) + .options( + joinedload(Draft.topics), + joinedload(Draft.authors) + ) + .filter(Draft.id == draft_id) + .first() + ) + if not draft: return {"error": "Draft not found"} + + # Создаем публикацию из черновика shout = create_shout_from_draft(session, draft, author_id) session.add(shout) + + # Добавляем авторов публикации + sa = ShoutAuthor(shout=shout.id, author=author_id) + session.add(sa) + + # Добавляем темы публикации, если они есть + if draft.topics: + for topic in draft.topics: + st = ShoutTopic( + topic=topic.id, + shout=shout.id, + main=getattr(topic, "main", False) + ) + session.add(st) + + # Фиксируем изменения + session.flush() + + # Отправляем уведомления + try: + # Преобразуем черновик в словарь для уведомления + draft_dict = draft.__dict__.copy() + # Удаляем служебные поля SQLAlchemy + draft_dict.pop('_sa_instance_state', None) + # Отправляем уведомление + await notify_draft(draft_dict, action="publish") + except Exception as e: + logger.error(f"Failed to send notification for draft {draft_id}: {e}") + session.commit() + + # Инвалидируем кэш после публикации + try: + await invalidate_shouts_cache() + await invalidate_shout_related_cache(shout.slug) + except Exception as e: + logger.error(f"Failed to invalidate cache: {e}") + return {"shout": shout, "draft": draft} @mutation.field("unpublish_draft") @login_required async def unpublish_draft(_, info, draft_id: int): + """Снимает черновик с публикации. + + Загружает связанные объекты заранее, чтобы избежать ошибок с отсоединенными + объектами при сериализации. + + Args: + draft_id: ID черновика + + Returns: + dict: Снятый с публикации черновик и публикация или сообщение об ошибке + """ user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") @@ -260,14 +332,47 @@ async def unpublish_draft(_, info, draft_id: int): return {"error": "User ID and author ID are required"} with local_session() as session: - draft = session.query(Draft).filter(Draft.id == draft_id).first() + # Загружаем черновик со связанными объектами + draft = ( + session.query(Draft) + .options( + joinedload(Draft.topics), + joinedload(Draft.authors) + ) + .filter(Draft.id == draft_id) + .first() + ) + if not draft: return {"error": "Draft not found"} + shout = session.query(Shout).filter(Shout.draft == draft.id).first() if shout: shout.published_at = None + + # Отправляем уведомления + try: + # Преобразуем черновик в словарь для уведомления + draft_dict = draft.__dict__.copy() + # Удаляем служебные поля SQLAlchemy + draft_dict.pop('_sa_instance_state', None) + # Отправляем уведомление + await notify_draft(draft_dict, action="unpublish") + except Exception as e: + logger.error(f"Failed to send notification for draft {draft_id}: {e}") + session.commit() + + # Инвалидируем кэш после снятия с публикации + try: + await invalidate_shouts_cache() + if shout.slug: + await invalidate_shout_related_cache(shout.slug) + except Exception as e: + logger.error(f"Failed to invalidate cache: {e}") + return {"shout": shout, "draft": draft} + return {"error": "Failed to unpublish draft"} From a310d59432964eea8477c21b3ac3cd6ed416d48f Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 11:45:16 +0300 Subject: [PATCH 03/36] draft-resolvers --- resolvers/draft.py | 91 +++++++++++++++++++++++++++++++++++++++++++++- services/schema.py | 5 ++- 2 files changed, 92 insertions(+), 4 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 4296e948..9d3af807 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -19,11 +19,10 @@ from orm.topic import Topic from services.auth import login_required from services.db import local_session from services.notify import notify_shout, notify_draft -from services.schema import mutation, query +from services.schema import mutation, query, type_draft from services.search import search_service from utils.logger import root_logger as logger - def create_shout_from_draft(session, draft, author_id): # Создаем новую публикацию shout = Shout( @@ -49,6 +48,15 @@ def create_shout_from_draft(session, draft, author_id): @query.field("load_drafts") @login_required async def load_drafts(_, info): + """ + Загружает все черновики, доступные текущему пользователю. + + Предварительно загружает связанные объекты (topics, authors), чтобы избежать + ошибок с отсоединенными объектами при сериализации. + + Returns: + dict: Список черновиков или сообщение об ошибке + """ user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") @@ -59,9 +67,14 @@ async def load_drafts(_, info): with local_session() as session: drafts = ( session.query(Draft) + .options( + joinedload(Draft.topics), + joinedload(Draft.authors) + ) .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) .all() ) + return {"drafts": drafts} @@ -515,3 +528,77 @@ async def unpublish_shout(_, info, shout_id: int): return {"error": "Failed to unpublish shout"} return {"shout": shout} + +# Добавляем резолверы для полей типа Draft +@type_draft.field("authors") +def resolve_draft_authors(draft, info): + """ + Резолвер для поля authors типа Draft. + + Безопасно загружает связанные объекты authors для объекта Draft, + используя новую сессию для предотвращения ошибок с отсоединенными объектами. + + Args: + draft: Объект Draft + info: Контекст GraphQL запроса + + Returns: + list: Список авторов или пустой список в случае ошибки + """ + try: + # Пробуем использовать уже загруженные авторы, если есть + if draft.authors and not isinstance(draft.authors, property): + return draft.authors + + # Загружаем с новой сессией + with local_session() as session: + loaded_draft = ( + session.query(Draft) + .options(joinedload(Draft.authors)) + .filter(Draft.id == draft.id) + .first() + ) + return loaded_draft.authors if loaded_draft else [] + + except Exception as e: + logger.error(f"Error resolving draft authors: {e}") + + # Возвращаем пустой список в случае ошибки + return [] + + +@type_draft.field("topics") +def resolve_draft_topics(draft, info): + """ + Резолвер для поля topics типа Draft. + + Безопасно загружает связанные объекты topics для объекта Draft, + используя новую сессию для предотвращения ошибок с отсоединенными объектами. + + Args: + draft: Объект Draft + info: Контекст GraphQL запроса + + Returns: + list: Список тем или пустой список в случае ошибки + """ + try: + # Пробуем использовать уже загруженные темы, если есть + if draft.topics and not isinstance(draft.topics, property): + return draft.topics + + # Загружаем с новой сессией + with local_session() as session: + loaded_draft = ( + session.query(Draft) + .options(joinedload(Draft.topics)) + .filter(Draft.id == draft.id) + .first() + ) + return loaded_draft.topics if loaded_draft else [] + + except Exception as e: + logger.error(f"Error resolving draft topics: {e}") + + # Возвращаем пустой список в случае ошибки + return [] diff --git a/services/schema.py b/services/schema.py index b8098575..8137bd64 100644 --- a/services/schema.py +++ b/services/schema.py @@ -1,14 +1,15 @@ from asyncio.log import logger import httpx -from ariadne import MutationType, QueryType +from ariadne import MutationType, ObjectType, QueryType from services.db import create_table_if_not_exists, local_session from settings import AUTH_URL query = QueryType() mutation = MutationType() -resolvers = [query, mutation] +type_draft = ObjectType("Draft") +resolvers = [query, mutation, type_draft] async def request_graphql_data(gql, url=AUTH_URL, headers=None): From bdae2abe253082417de981e55ee867025ea88184 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 13:11:12 +0300 Subject: [PATCH 04/36] drafts schema restore + publish/unpublish fixes --- orm/draft.py | 27 ++++- resolvers/__init__.py | 4 +- resolvers/draft.py | 256 ++---------------------------------------- resolvers/editor.py | 76 ++++++++++++- 4 files changed, 107 insertions(+), 256 deletions(-) diff --git a/orm/draft.py b/orm/draft.py index 1c669f02..c634e406 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -26,12 +26,14 @@ class DraftAuthor(Base): caption = Column(String, nullable=True, default="") + class Draft(Base): __tablename__ = "draft" # required created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time())) - created_by: int = Column(ForeignKey("author.id"), nullable=False) - community: int = Column(ForeignKey("community.id"), nullable=False, default=1) + # Переименовываем колонки ID, чтобы избежать конфликта имен с relationship + created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False) + community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1) # optional layout: str = Column(String, nullable=True, default="article") @@ -49,7 +51,20 @@ class Draft(Base): # auto updated_at: int | None = Column(Integer, nullable=True, index=True) deleted_at: int | None = Column(Integer, nullable=True, index=True) - updated_by: int | None = Column(ForeignKey("author.id"), nullable=True) - deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True) - authors = relationship(Author, secondary="draft_author") - topics = relationship(Topic, secondary="draft_topic") + # Переименовываем колонки ID + updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True) + deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True) + + # --- Relationships --- + # Загружаем этих авторов сразу, т.к. они часто нужны и их немного (обычно 1) + created_by = relationship("Author", foreign_keys=[created_by], lazy="joined", innerjoin=True) + updated_by = relationship("Author", foreign_keys=[updated_by], lazy="joined") + deleted_by = relationship("Author", foreign_keys=[deleted_by], lazy="joined") + + # Оставляем lazy="select" (по умолчанию) для коллекций, будем загружать их через joinedload в запросах + authors = relationship(Author, secondary="draft_author", lazy="select") + topics = relationship(Topic, secondary="draft_topic", lazy="select") + + # Связь с Community (если нужна как объект, а не ID) + # community = relationship("Community", foreign_keys=[community_id], lazy="joined") + # Пока оставляем community_id как ID \ No newline at end of file diff --git a/resolvers/__init__.py b/resolvers/__init__.py index 699bc4c4..e781d571 100644 --- a/resolvers/__init__.py +++ b/resolvers/__init__.py @@ -16,9 +16,11 @@ from resolvers.draft import ( delete_draft, load_drafts, publish_draft, - unpublish_draft, update_draft, ) +from resolvers.editor import ( + unpublish_shout, +) from resolvers.feed import ( load_shouts_coauthored, load_shouts_discussed, diff --git a/resolvers/draft.py b/resolvers/draft.py index 9d3af807..e095d4c1 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -18,8 +18,8 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from services.auth import login_required from services.db import local_session -from services.notify import notify_shout, notify_draft -from services.schema import mutation, query, type_draft +from services.notify import notify_shout +from services.schema import mutation, query from services.search import search_service from utils.logger import root_logger as logger @@ -65,13 +65,16 @@ async def load_drafts(_, info): return {"error": "User ID and author ID are required"} with local_session() as session: + # Предзагружаем authors и topics, т.к. они lazy='select' в модели + # created_by, updated_by, deleted_by загрузятся автоматически (lazy='joined') drafts = ( session.query(Draft) .options( joinedload(Draft.topics), joinedload(Draft.authors) ) - .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) + # Фильтруем по ID автора (создатель или соавтор) + .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by_id == author_id)) .all() ) @@ -264,149 +267,12 @@ async def publish_draft(_, info, draft_id: int): author_id = author_dict.get("id") if not user_id or not author_id: return {"error": "User ID and author ID are required"} - - with local_session() as session: - # Загружаем черновик со связанными объектами (topics, authors) - draft = ( - session.query(Draft) - .options( - joinedload(Draft.topics), - joinedload(Draft.authors) - ) - .filter(Draft.id == draft_id) - .first() - ) - - if not draft: - return {"error": "Draft not found"} - - # Создаем публикацию из черновика - shout = create_shout_from_draft(session, draft, author_id) - session.add(shout) - - # Добавляем авторов публикации - sa = ShoutAuthor(shout=shout.id, author=author_id) - session.add(sa) - - # Добавляем темы публикации, если они есть - if draft.topics: - for topic in draft.topics: - st = ShoutTopic( - topic=topic.id, - shout=shout.id, - main=getattr(topic, "main", False) - ) - session.add(st) - - # Фиксируем изменения - session.flush() - - # Отправляем уведомления - try: - # Преобразуем черновик в словарь для уведомления - draft_dict = draft.__dict__.copy() - # Удаляем служебные поля SQLAlchemy - draft_dict.pop('_sa_instance_state', None) - # Отправляем уведомление - await notify_draft(draft_dict, action="publish") - except Exception as e: - logger.error(f"Failed to send notification for draft {draft_id}: {e}") - - session.commit() - - # Инвалидируем кэш после публикации - try: - await invalidate_shouts_cache() - await invalidate_shout_related_cache(shout.slug) - except Exception as e: - logger.error(f"Failed to invalidate cache: {e}") - - return {"shout": shout, "draft": draft} - - -@mutation.field("unpublish_draft") -@login_required -async def unpublish_draft(_, info, draft_id: int): - """Снимает черновик с публикации. - Загружает связанные объекты заранее, чтобы избежать ошибок с отсоединенными - объектами при сериализации. - - Args: - draft_id: ID черновика - - Returns: - dict: Снятый с публикации черновик и публикация или сообщение об ошибке - """ - user_id = info.context.get("user_id") - author_dict = info.context.get("author", {}) - author_id = author_dict.get("id") - if not user_id or not author_id: - return {"error": "User ID and author ID are required"} - - with local_session() as session: - # Загружаем черновик со связанными объектами - draft = ( - session.query(Draft) - .options( - joinedload(Draft.topics), - joinedload(Draft.authors) - ) - .filter(Draft.id == draft_id) - .first() - ) - - if not draft: - return {"error": "Draft not found"} - - shout = session.query(Shout).filter(Shout.draft == draft.id).first() - if shout: - shout.published_at = None - - # Отправляем уведомления - try: - # Преобразуем черновик в словарь для уведомления - draft_dict = draft.__dict__.copy() - # Удаляем служебные поля SQLAlchemy - draft_dict.pop('_sa_instance_state', None) - # Отправляем уведомление - await notify_draft(draft_dict, action="unpublish") - except Exception as e: - logger.error(f"Failed to send notification for draft {draft_id}: {e}") - - session.commit() - - # Инвалидируем кэш после снятия с публикации - try: - await invalidate_shouts_cache() - if shout.slug: - await invalidate_shout_related_cache(shout.slug) - except Exception as e: - logger.error(f"Failed to invalidate cache: {e}") - - return {"shout": shout, "draft": draft} - - return {"error": "Failed to unpublish draft"} - - -@mutation.field("publish_shout") -@login_required -async def publish_shout(_, info, shout_id: int): - """Publish draft as a shout or update existing shout. - - Args: - shout_id: ID существующей публикации или 0 для новой - draft: Объект черновика (опционально) - """ - user_id = info.context.get("user_id") - author_dict = info.context.get("author", {}) - author_id = author_dict.get("id") now = int(time.time()) - if not user_id or not author_id: - return {"error": "User ID and author ID are required"} - + try: with local_session() as session: + shout_id = session.query(Draft.shout).filter(Draft.id == draft_id).first() shout = session.query(Shout).filter(Shout.id == shout_id).first() if not shout: return {"error": "Shout not found"} @@ -496,109 +362,3 @@ async def publish_shout(_, info, shout_id: int): if "session" in locals(): session.rollback() return {"error": f"Failed to publish shout: {str(e)}"} - - -@mutation.field("unpublish_shout") -@login_required -async def unpublish_shout(_, info, shout_id: int): - """Unpublish a shout. - - Args: - shout_id: The ID of the shout to unpublish - - Returns: - dict: The unpublished shout or an error message - """ - author_dict = info.context.get("author", {}) - author_id = author_dict.get("id") - if not author_id: - return {"error": "Author ID is required"} - - shout = None - with local_session() as session: - try: - shout = session.query(Shout).filter(Shout.id == shout_id).first() - shout.published_at = None - session.commit() - invalidate_shout_related_cache(shout) - invalidate_shouts_cache() - - except Exception: - session.rollback() - return {"error": "Failed to unpublish shout"} - - return {"shout": shout} - -# Добавляем резолверы для полей типа Draft -@type_draft.field("authors") -def resolve_draft_authors(draft, info): - """ - Резолвер для поля authors типа Draft. - - Безопасно загружает связанные объекты authors для объекта Draft, - используя новую сессию для предотвращения ошибок с отсоединенными объектами. - - Args: - draft: Объект Draft - info: Контекст GraphQL запроса - - Returns: - list: Список авторов или пустой список в случае ошибки - """ - try: - # Пробуем использовать уже загруженные авторы, если есть - if draft.authors and not isinstance(draft.authors, property): - return draft.authors - - # Загружаем с новой сессией - with local_session() as session: - loaded_draft = ( - session.query(Draft) - .options(joinedload(Draft.authors)) - .filter(Draft.id == draft.id) - .first() - ) - return loaded_draft.authors if loaded_draft else [] - - except Exception as e: - logger.error(f"Error resolving draft authors: {e}") - - # Возвращаем пустой список в случае ошибки - return [] - - -@type_draft.field("topics") -def resolve_draft_topics(draft, info): - """ - Резолвер для поля topics типа Draft. - - Безопасно загружает связанные объекты topics для объекта Draft, - используя новую сессию для предотвращения ошибок с отсоединенными объектами. - - Args: - draft: Объект Draft - info: Контекст GraphQL запроса - - Returns: - list: Список тем или пустой список в случае ошибки - """ - try: - # Пробуем использовать уже загруженные темы, если есть - if draft.topics and not isinstance(draft.topics, property): - return draft.topics - - # Загружаем с новой сессией - with local_session() as session: - loaded_draft = ( - session.query(Draft) - .options(joinedload(Draft.topics)) - .filter(Draft.id == draft.id) - .first() - ) - return loaded_draft.topics if loaded_draft else [] - - except Exception as e: - logger.error(f"Error resolving draft topics: {e}") - - # Возвращаем пустой список в случае ошибки - return [] diff --git a/resolvers/editor.py b/resolvers/editor.py index 6d0b396f..934c7fc5 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -20,7 +20,7 @@ from resolvers.stat import get_with_stat from services.auth import login_required from services.db import local_session from services.notify import notify_shout -from services.schema import query +from services.schema import mutation, query from services.search import search_service from utils.logger import root_logger as logger @@ -681,3 +681,77 @@ def get_main_topic(topics): logger.warning("No valid topics found, returning default") return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True} + + + +@mutation.field("unpublish_shout") +@login_required +async def unpublish_shout(_, info, shout_id: int): + """Снимает публикацию (shout) с публикации. + + Предзагружает связанный черновик (draft) и его авторов/темы, чтобы избежать + ошибок при последующем доступе к ним в GraphQL. + + Args: + shout_id: ID публикации для снятия с публикации + + Returns: + dict: Снятая с публикации публикация или сообщение об ошибке + """ + author_dict = info.context.get("author", {}) + author_id = author_dict.get("id") + if not author_id: + # В идеале нужна проверка прав, имеет ли автор право снимать публикацию + return {"error": "Author ID is required"} + + shout = None + with local_session() as session: + try: + # Загружаем Shout с предзагрузкой draft и его связей authors/topics + # Используем selectinload для коллекций authors/topics внутри draft - + # это может быть эффективнее joinedload, если draft один. + shout = ( + session.query(Shout) + .options( + joinedload(Shout.draft) # Загружаем сам черновик + .selectinload(Draft.authors), # Загружаем авторов черновика через отдельный запрос + joinedload(Shout.draft) + .selectinload(Draft.topics) # Загружаем темы черновика через отдельный запрос + # Также предзагружаем авторов самой публикации, если они нужны для проверки прав или возврата + # selectinload(Shout.authors) + ) + .filter(Shout.id == shout_id) + .first() + ) + + if not shout: + logger.warning(f"Shout not found for unpublish: ID {shout_id}") + return {"error": "Shout not found"} + + # TODO: Добавить проверку прав доступа, если необходимо + # if author_id not in [a.id for a in shout.authors]: # Требует selectinload(Shout.authors) выше + # logger.warning(f"Author {author_id} denied unpublishing shout {shout_id}") + # return {"error": "Access denied"} + + shout.published_at = None + session.commit() + + # Инвалидация кэша + try: + # Передаем slug или ID, если slug нет + cache_key = shout.slug if shout.slug else shout.id + await invalidate_shout_related_cache(cache_key) + await invalidate_shouts_cache() + logger.info(f"Cache invalidated after unpublishing shout {shout_id}") + except Exception as cache_err: + logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}") + + + except Exception as e: + session.rollback() + logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True) + return {"error": "Failed to unpublish shout"} + + # Возвращаем объект shout с предзагруженным draft и его связями + logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}") + return {"shout": shout} \ No newline at end of file From e7684c9c05fd886932f79e743213dfac2c967042 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 14:10:05 +0300 Subject: [PATCH 05/36] reaction-by-upgrade --- resolvers/reaction.py | 6 +++++- schema/input.graphql | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/resolvers/reaction.py b/resolvers/reaction.py index 4a3fe56a..b10de0bf 100644 --- a/resolvers/reaction.py +++ b/resolvers/reaction.py @@ -487,12 +487,16 @@ def apply_reaction_filters(by, q): shout_slug = by.get("shout") if shout_slug: q = q.filter(Shout.slug == shout_slug) + + shout_id = by.get("shout_id") + if shout_id: + q = q.filter(Shout.id == shout_id) shouts = by.get("shouts") if shouts: q = q.filter(Shout.slug.in_(shouts)) - created_by = by.get("created_by") + created_by = by.get("created_by", by.get("author_id")) if created_by: q = q.filter(Author.id == created_by) diff --git a/schema/input.graphql b/schema/input.graphql index c1637723..fba7b144 100644 --- a/schema/input.graphql +++ b/schema/input.graphql @@ -92,12 +92,14 @@ input LoadShoutsOptions { input ReactionBy { shout: String + shout_id: Int shouts: [String] search: String kinds: [ReactionKind] reply_to: Int # filter topic: String created_by: Int + author_id: Int author: String after: Int sort: ReactionSort # sort From e4943f524c547b958bcc87d10102e3a3838dfaf6 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 15:35:31 +0300 Subject: [PATCH 06/36] reaction-by-upgrade2 --- resolvers/editor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/resolvers/editor.py b/resolvers/editor.py index 934c7fc5..8bf65817 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -13,6 +13,7 @@ from cache.cache import ( invalidate_shouts_cache, ) from orm.author import Author +from orm.draft import Draft from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from resolvers.follower import follow, unfollow From 3b3cc1c1d8e696bf2ca66163f733e0152c9d2c4b Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 15:42:15 +0300 Subject: [PATCH 07/36] =?UTF-8?q?reaction-by-=D0=B0=D1=88=D1=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- resolvers/draft.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index e095d4c1..76ea0223 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -135,8 +135,8 @@ async def create_draft(_, info, draft_input): # Добавляем текущее время создания draft_input["created_at"] = int(time.time()) - - draft = Draft(created_by=author_id, **draft_input) + author = session.query(Author).filter(Author.id == author_id).first() + draft = Draft(created_by=author, **draft_input) session.add(draft) session.commit() return {"draft": draft} From 631ad47fe8b59ffe2c4b38c10c5e5723082520c2 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 15:47:44 +0300 Subject: [PATCH 08/36] reaction-by-fix2 --- orm/draft.py | 8 ++------ resolvers/draft.py | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/orm/draft.py b/orm/draft.py index c634e406..33fd5fa4 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -51,16 +51,12 @@ class Draft(Base): # auto updated_at: int | None = Column(Integer, nullable=True, index=True) deleted_at: int | None = Column(Integer, nullable=True, index=True) - # Переименовываем колонки ID + updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True) deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True) # --- Relationships --- - # Загружаем этих авторов сразу, т.к. они часто нужны и их немного (обычно 1) - created_by = relationship("Author", foreign_keys=[created_by], lazy="joined", innerjoin=True) - updated_by = relationship("Author", foreign_keys=[updated_by], lazy="joined") - deleted_by = relationship("Author", foreign_keys=[deleted_by], lazy="joined") - + # Оставляем lazy="select" (по умолчанию) для коллекций, будем загружать их через joinedload в запросах authors = relationship(Author, secondary="draft_author", lazy="select") topics = relationship(Topic, secondary="draft_topic", lazy="select") diff --git a/resolvers/draft.py b/resolvers/draft.py index 76ea0223..b3371903 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -241,7 +241,7 @@ async def delete_draft(_, info, draft_id: int): draft = session.query(Draft).filter(Draft.id == draft_id).first() if not draft: return {"error": "Draft not found"} - if author_id != draft.created_by and draft.authors.filter(Author.id == author_id).count() == 0: + if author_id != draft.created_by_id and draft.authors.filter(Author.id == author_id).count() == 0: return {"error": "You are not allowed to delete this draft"} session.delete(draft) session.commit() From af7fbd2fc91e2674cb335e1bf58b0f7f6c9d9a89 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 15:50:20 +0300 Subject: [PATCH 09/36] reaction-by-fix3 --- orm/draft.py | 6 ++---- resolvers/draft.py | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/orm/draft.py b/orm/draft.py index 33fd5fa4..f3b1f100 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -31,7 +31,7 @@ class Draft(Base): __tablename__ = "draft" # required created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time())) - # Переименовываем колонки ID, чтобы избежать конфликта имен с relationship + # Колонки для связей с автором created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False) community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1) @@ -51,13 +51,11 @@ class Draft(Base): # auto updated_at: int | None = Column(Integer, nullable=True, index=True) deleted_at: int | None = Column(Integer, nullable=True, index=True) - updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True) deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True) # --- Relationships --- - - # Оставляем lazy="select" (по умолчанию) для коллекций, будем загружать их через joinedload в запросах + # Только many-to-many связи через вспомогательные таблицы authors = relationship(Author, secondary="draft_author", lazy="select") topics = relationship(Topic, secondary="draft_topic", lazy="select") diff --git a/resolvers/draft.py b/resolvers/draft.py index b3371903..0f40afa9 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -74,7 +74,7 @@ async def load_drafts(_, info): joinedload(Draft.authors) ) # Фильтруем по ID автора (создатель или соавтор) - .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by_id == author_id)) + .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) .all() ) @@ -133,10 +133,10 @@ async def create_draft(_, info, draft_input): if "id" in draft_input: del draft_input["id"] - # Добавляем текущее время создания + # Добавляем текущее время создания и ID автора draft_input["created_at"] = int(time.time()) - author = session.query(Author).filter(Author.id == author_id).first() - draft = Draft(created_by=author, **draft_input) + draft_input["created_by"] = author_id + draft = Draft(**draft_input) session.add(draft) session.commit() return {"draft": draft} @@ -223,7 +223,7 @@ async def update_draft(_, info, draft_id: int, draft_input): # Set updated timestamp and author current_time = int(time.time()) draft.updated_at = current_time - draft.updated_by = author_id # Assuming author_id is correctly fetched context + draft.updated_by = author_id # Используем ID напрямую session.commit() # Invalidate cache related to this draft if necessary (consider adding) From 6d9513f1b2bc40a5d8e50f1110d6a24bbb13cd75 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 15:57:51 +0300 Subject: [PATCH 10/36] reaction-by-fix4 --- resolvers/draft.py | 51 ++++++++++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 0f40afa9..a8256e3f 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -64,21 +64,32 @@ async def load_drafts(_, info): if not user_id or not author_id: return {"error": "User ID and author ID are required"} - with local_session() as session: - # Предзагружаем authors и topics, т.к. они lazy='select' в модели - # created_by, updated_by, deleted_by загрузятся автоматически (lazy='joined') - drafts = ( - session.query(Draft) - .options( - joinedload(Draft.topics), - joinedload(Draft.authors) + try: + with local_session() as session: + # Предзагружаем authors и topics + drafts = ( + session.query(Draft) + .options( + joinedload(Draft.topics), + joinedload(Draft.authors) + ) + # Фильтруем по ID автора (создатель или соавтор) + .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) + .all() ) - # Фильтруем по ID автора (создатель или соавтор) - .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) - .all() - ) - return {"drafts": drafts} + # Преобразуем объекты в словари, пока они в контексте сессии + drafts_data = [] + for draft in drafts: + draft_dict = draft.dict() + draft_dict["topics"] = [topic.dict() for topic in draft.topics] + draft_dict["authors"] = [author.dict() for author in draft.authors] + drafts_data.append(draft_dict) + + return {"drafts": drafts_data} + except Exception as e: + logger.error(f"Failed to load drafts: {e}", exc_info=True) + return {"error": f"Failed to load drafts: {str(e)}"} @mutation.field("create_draft") @@ -272,17 +283,17 @@ async def publish_draft(_, info, draft_id: int): try: with local_session() as session: - shout_id = session.query(Draft.shout).filter(Draft.id == draft_id).first() - shout = session.query(Shout).filter(Shout.id == shout_id).first() - if not shout: - return {"error": "Shout not found"} - was_published = shout.published_at is not None - draft = session.query(Draft).where(Draft.id == shout.draft).first() + # Сначала находим черновик + draft = session.query(Draft).filter(Draft.id == draft_id).first() if not draft: return {"error": "Draft not found"} - # Находим черновик если не передан + + # Ищем существующий shout для этого черновика + shout = session.query(Shout).filter(Shout.draft == draft_id).first() + was_published = shout.published_at if shout else None if not shout: + # Создаем новый shout если не существует shout = create_shout_from_draft(session, draft, author_id) else: # Обновляем существующую публикацию From b66e347c91b003571bce65d23c5fa58d4ec1361a Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 16:03:41 +0300 Subject: [PATCH 11/36] draft-create-fix --- resolvers/draft.py | 139 ++++++++++++++++++++++++++++----------------- 1 file changed, 86 insertions(+), 53 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index a8256e3f..a03b0051 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -13,7 +13,7 @@ from cache.cache import ( invalidate_shouts_cache, ) from orm.author import Author -from orm.draft import Draft +from orm.draft import Draft, DraftAuthor, DraftTopic from orm.shout import Shout, ShoutAuthor, ShoutTopic from orm.topic import Topic from services.auth import login_required @@ -168,7 +168,21 @@ async def update_draft(_, info, draft_id: int, draft_input): Args: draft_id: ID черновика для обновления - draft_input: Данные для обновления черновика + draft_input: Данные для обновления черновика согласно схеме DraftInput: + - layout: String + - author_ids: [Int!] + - topic_ids: [Int!] + - main_topic_id: Int + - media: [MediaItemInput] + - lead: String + - subtitle: String + - lang: String + - seo: String + - body: String + - title: String + - slug: String + - cover: String + - cover_caption: String Returns: dict: Обновленный черновик или сообщение об ошибке @@ -180,66 +194,85 @@ async def update_draft(_, info, draft_id: int, draft_input): if not user_id or not author_id: return {"error": "Author ID are required"} - # Проверяем slug - он должен быть или не пустым, или не передаваться вообще - if "slug" in draft_input and (draft_input["slug"] is None or draft_input["slug"] == ""): - # Если slug пустой, либо удаляем его из входных данных, либо генерируем временный уникальный - # Вариант 1: просто удаляем ключ из входных данных, чтобы оставить старое значение - del draft_input["slug"] - # Вариант 2 (если нужно обновить): генерируем временный уникальный slug - # import uuid - # draft_input["slug"] = f"draft-{uuid.uuid4().hex[:8]}" + try: + with local_session() as session: + draft = session.query(Draft).filter(Draft.id == draft_id).first() + if not draft: + return {"error": "Draft not found"} - with local_session() as session: - draft = session.query(Draft).filter(Draft.id == draft_id).first() - if not draft: - return {"error": "Draft not found"} + # Фильтруем входные данные, оставляя только разрешенные поля + allowed_fields = { + "layout", "author_ids", "topic_ids", "main_topic_id", + "media", "lead", "subtitle", "lang", "seo", "body", + "title", "slug", "cover", "cover_caption" + } + filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields} - # Generate SEO description if not provided and not already set - if "seo" not in draft_input and not draft.seo: - body_src = draft_input.get("body") if "body" in draft_input else draft.body - lead_src = draft_input.get("lead") if "lead" in draft_input else draft.lead + # Проверяем slug + if "slug" in filtered_input and not filtered_input["slug"]: + del filtered_input["slug"] - body_text = None - if body_src: + # Обновляем связи с авторами если переданы + if "author_ids" in filtered_input: + author_ids = filtered_input.pop("author_ids") + if author_ids: + # Очищаем текущие связи + session.query(DraftAuthor).filter(DraftAuthor.shout == draft_id).delete() + # Добавляем новые связи + for aid in author_ids: + da = DraftAuthor(shout=draft_id, author=aid) + session.add(da) + + # Обновляем связи с темами если переданы + if "topic_ids" in filtered_input: + topic_ids = filtered_input.pop("topic_ids") + main_topic_id = filtered_input.pop("main_topic_id", None) + if topic_ids: + # Очищаем текущие связи + session.query(DraftTopic).filter(DraftTopic.shout == draft_id).delete() + # Добавляем новые связи + for tid in topic_ids: + dt = DraftTopic( + shout=draft_id, + topic=tid, + main=(tid == main_topic_id) if main_topic_id else False + ) + session.add(dt) + + # Генерируем SEO если не предоставлено + if "seo" not in filtered_input and not draft.seo: + body_src = filtered_input.get("body", draft.body) + lead_src = filtered_input.get("lead", draft.lead) + try: - # Extract text, excluding comments and tables - body_text = trafilatura.extract(body_src, include_comments=False, include_tables=False) + body_text = trafilatura.extract(body_src, include_comments=False, include_tables=False) if body_src else None + lead_text = trafilatura.extract(lead_src, include_comments=False, include_tables=False) if lead_src else None + + body_teaser = generate_teaser(body_text, 300) if body_text else "" + filtered_input["seo"] = lead_text if lead_text else body_teaser except Exception as e: - logger.warning(f"Trafilatura failed to extract body text for draft {draft_id}: {e}") + logger.warning(f"Failed to generate SEO for draft {draft_id}: {e}") - lead_text = None - if lead_src: - try: - # Extract text from lead - lead_text = trafilatura.extract(lead_src, include_comments=False, include_tables=False) - except Exception as e: - logger.warning(f"Trafilatura failed to extract lead text for draft {draft_id}: {e}") + # Обновляем основные поля черновика + for key, value in filtered_input.items(): + setattr(draft, key, value) - # Generate body teaser only if body_text was successfully extracted - body_teaser = generate_teaser(body_text, 300) if body_text else "" + # Обновляем метаданные + draft.updated_at = int(time.time()) + draft.updated_by = author_id - # Prioritize lead_text for SEO, fallback to body_teaser. Ensure it's a string. - generated_seo = lead_text if lead_text else body_teaser - draft_input["seo"] = generated_seo if generated_seo else "" + session.commit() + + # Преобразуем объект в словарь для ответа + draft_dict = draft.dict() + draft_dict["topics"] = [topic.dict() for topic in draft.topics] + draft_dict["authors"] = [author.dict() for author in draft.authors] + + return {"draft": draft_dict} - # Update the draft object with new data from draft_input - # Assuming Draft.update is a helper that iterates keys or similar. - # A more standard SQLAlchemy approach would be: - # for key, value in draft_input.items(): - # if hasattr(draft, key): - # setattr(draft, key, value) - # But we stick to the existing pattern for now. - Draft.update(draft, draft_input) - - # Set updated timestamp and author - current_time = int(time.time()) - draft.updated_at = current_time - draft.updated_by = author_id # Используем ID напрямую - - session.commit() - # Invalidate cache related to this draft if necessary (consider adding) - # await invalidate_draft_cache(draft_id) - return {"draft": draft} + except Exception as e: + logger.error(f"Failed to update draft: {e}", exc_info=True) + return {"error": f"Failed to update draft: {str(e)}"} @mutation.field("delete_draft") From dfbdfba2f0a27dbe563b70d194a556e19760d79f Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 16:13:07 +0300 Subject: [PATCH 12/36] draft-create-fix5 --- resolvers/draft.py | 56 +++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index a03b0051..1f2fb8ca 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -24,6 +24,28 @@ from services.search import search_service from utils.logger import root_logger as logger def create_shout_from_draft(session, draft, author_id): + """ + Создаёт новый объект публикации (Shout) на основе черновика. + + Args: + session: SQLAlchemy сессия (не используется, для совместимости) + draft (Draft): Объект черновика + author_id (int): ID автора публикации + + Returns: + Shout: Новый объект публикации (не сохранённый в базе) + + Пример: + >>> from orm.draft import Draft + >>> draft = Draft(id=1, title='Заголовок', body='Текст', slug='slug', created_by=1) + >>> shout = create_shout_from_draft(None, draft, 1) + >>> shout.title + 'Заголовок' + >>> shout.body + 'Текст' + >>> shout.created_by + 1 + """ # Создаем новую публикацию shout = Shout( body=draft.body, @@ -328,6 +350,7 @@ async def publish_draft(_, info, draft_id: int): if not shout: # Создаем новый shout если не существует shout = create_shout_from_draft(session, draft, author_id) + shout.published_at = now else: # Обновляем существующую публикацию shout.draft = draft.id @@ -342,34 +365,37 @@ async def publish_draft(_, info, draft_id: int): shout.media = draft.media shout.lang = draft.lang shout.seo = draft.seo - - draft.updated_at = now shout.updated_at = now - + # Устанавливаем published_at только если была ранее снята с публикации if not was_published: shout.published_at = now - # Обрабатываем связи с авторами - if ( - not session.query(ShoutAuthor) - .filter(and_(ShoutAuthor.shout == shout.id, ShoutAuthor.author == author_id)) - .first() - ): - sa = ShoutAuthor(shout=shout.id, author=author_id) - session.add(sa) + # Сохраняем shout перед созданием связей + session.add(shout) + session.flush() - # Обрабатываем темы + # Очищаем существующие связи + session.query(ShoutAuthor).filter(ShoutAuthor.shout == shout.id).delete() + session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete() + + # Добавляем автора + sa = ShoutAuthor(shout=shout.id, author=author_id) + session.add(sa) + + # Добавляем темы если есть if draft.topics: for topic in draft.topics: st = ShoutTopic( - topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False + topic=topic.id, + shout=shout.id, + main=topic.main if hasattr(topic, "main") else False ) session.add(st) - session.add(shout) + # Обновляем черновик + draft.updated_at = now session.add(draft) - session.flush() # Инвалидируем кэш только если это новая публикация или была снята с публикации if not was_published: From 0939e9170013539a603c6c4c88e0dac5dac2dda4 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 16:19:33 +0300 Subject: [PATCH 13/36] empty-body-fix --- resolvers/draft.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resolvers/draft.py b/resolvers/draft.py index 1f2fb8ca..fddd5ecc 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -343,6 +343,10 @@ async def publish_draft(_, info, draft_id: int): if not draft: return {"error": "Draft not found"} + # Проверка на пустой body + if not draft.body or not draft.body.strip(): + return {"error": "Draft body is empty, cannot publish."} + # Ищем существующий shout для этого черновика shout = session.query(Shout).filter(Shout.draft == draft_id).first() was_published = shout.published_at if shout else None From 4cd8883d725062413767033d77af0d8b31f1d51e Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 17:02:55 +0300 Subject: [PATCH 14/36] validhtmlfix --- resolvers/draft.py | 42 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index fddd5ecc..2fcb2213 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -314,6 +314,41 @@ async def delete_draft(_, info, draft_id: int): return {"draft": draft} +def validate_html_content(html_content: str) -> tuple[bool, str]: + """ + Проверяет валидность HTML контента через trafilatura. + + Args: + html_content: HTML строка для проверки + + Returns: + tuple[bool, str]: (валидность, сообщение об ошибке) + + Example: + >>> is_valid, error = validate_html_content("

Valid HTML

") + >>> is_valid + True + >>> error + '' + >>> is_valid, error = validate_html_content("Invalid < HTML") + >>> is_valid + False + >>> 'Invalid HTML' in error + True + """ + if not html_content or not html_content.strip(): + return False, "Content is empty" + + try: + extracted = trafilatura.extract(html_content) + if not extracted: + return False, "Invalid HTML structure or empty content" + return True, "" + except Exception as e: + logger.error(f"HTML validation error: {e}", exc_info=True) + return False, f"Invalid HTML content: {str(e)}" + + @mutation.field("publish_draft") @login_required async def publish_draft(_, info, draft_id: int): @@ -343,9 +378,10 @@ async def publish_draft(_, info, draft_id: int): if not draft: return {"error": "Draft not found"} - # Проверка на пустой body - if not draft.body or not draft.body.strip(): - return {"error": "Draft body is empty, cannot publish."} + # Проверка валидности HTML в body + is_valid, error = validate_html_content(draft.body) + if not is_valid: + return {"error": f"Cannot publish draft: {error}"} # Ищем существующий shout для этого черновика shout = session.query(Shout).filter(Shout.draft == draft_id).first() From bde3211a5f5ddf08dc92405926dc17d92ef615a5 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 17:07:02 +0300 Subject: [PATCH 15/36] updated-by-auto --- resolvers/draft.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 2fcb2213..16d595a0 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -289,7 +289,7 @@ async def update_draft(_, info, draft_id: int, draft_input): draft_dict = draft.dict() draft_dict["topics"] = [topic.dict() for topic in draft.topics] draft_dict["authors"] = [author.dict() for author in draft.authors] - + draft_dict["updated_by"] = author_id return {"draft": draft_dict} except Exception as e: @@ -465,7 +465,9 @@ async def publish_draft(_, info, draft_id: int): await notify_shout(shout.dict(), "update") session.commit() - return {"shout": shout} + shout_dict = shout.dict() + shout_dict["updated_by"] = author_id + return {"shout": shout_dict} except Exception as e: logger.error(f"Failed to publish shout: {e}", exc_info=True) From 20fd40df0e96bdf2e480565156035632153b1eb5 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 23:46:07 +0300 Subject: [PATCH 16/36] updated-by-auto-fix --- resolvers/draft.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 16d595a0..558a5da5 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -289,7 +289,9 @@ async def update_draft(_, info, draft_id: int, draft_input): draft_dict = draft.dict() draft_dict["topics"] = [topic.dict() for topic in draft.topics] draft_dict["authors"] = [author.dict() for author in draft.authors] - draft_dict["updated_by"] = author_id + # Добавляем объект автора в updated_by + draft_dict["updated_by"] = author_dict + return {"draft": draft_dict} except Exception as e: @@ -466,7 +468,8 @@ async def publish_draft(_, info, draft_id: int): session.commit() shout_dict = shout.dict() - shout_dict["updated_by"] = author_id + # Добавляем объект автора в updated_by + shout_dict["updated_by"] = author_dict return {"shout": shout_dict} except Exception as e: From b735bf8cabb195c399984d3e3bd0886e6c87986f Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 27 Apr 2025 09:15:07 +0300 Subject: [PATCH 17/36] draft-creator-adding --- resolvers/draft.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 558a5da5..9a4d9fe4 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -95,8 +95,7 @@ async def load_drafts(_, info): joinedload(Draft.topics), joinedload(Draft.authors) ) - # Фильтруем по ID автора (создатель или соавтор) - .filter(or_(Draft.authors.any(Author.id == author_id), Draft.created_by == author_id)) + .filter(Draft.authors.any(Author.id == author_id)) .all() ) @@ -171,6 +170,12 @@ async def create_draft(_, info, draft_input): draft_input["created_by"] = author_id draft = Draft(**draft_input) session.add(draft) + session.flush() + + # Добавляем создателя как автора + da = DraftAuthor(shout=draft.id, author=author_id) + session.add(da) + session.commit() return {"draft": draft} except Exception as e: From bcbfdd76e9c74bde192e35171d309055b1b15fef Mon Sep 17 00:00:00 2001 From: Untone Date: Sun, 27 Apr 2025 12:53:49 +0300 Subject: [PATCH 18/36] html wrap fix --- resolvers/draft.py | 11 ++++++++--- resolvers/editor.py | 9 ++++++--- utils/__init__.py | 1 + utils/html_wrapper.py | 38 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 utils/__init__.py create mode 100644 utils/html_wrapper.py diff --git a/resolvers/draft.py b/resolvers/draft.py index 9a4d9fe4..57df9330 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -21,6 +21,7 @@ from services.db import local_session from services.notify import notify_shout from services.schema import mutation, query from services.search import search_service +from utils.html_wrapper import wrap_html_fragment from utils.logger import root_logger as logger def create_shout_from_draft(session, draft, author_id): @@ -183,7 +184,8 @@ async def create_draft(_, info, draft_input): return {"error": f"Failed to create draft: {str(e)}"} def generate_teaser(body, limit=300): - body_text = trafilatura.extract(body, include_comments=False, include_tables=False) + body_html = wrap_html_fragment(body) + body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) body_teaser = ". ".join(body_text[:limit].split(". ")[:-1]) return body_teaser @@ -270,10 +272,12 @@ async def update_draft(_, info, draft_id: int, draft_input): if "seo" not in filtered_input and not draft.seo: body_src = filtered_input.get("body", draft.body) lead_src = filtered_input.get("lead", draft.lead) + body_html = wrap_html_fragment(body_src) + lead_html = wrap_html_fragment(lead_src) try: - body_text = trafilatura.extract(body_src, include_comments=False, include_tables=False) if body_src else None - lead_text = trafilatura.extract(lead_src, include_comments=False, include_tables=False) if lead_src else None + body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False) if body_src else None + lead_text = trafilatura.extract(lead_html, include_comments=False, include_tables=False) if lead_src else None body_teaser = generate_teaser(body_text, 300) if body_text else "" filtered_input["seo"] = lead_text if lead_text else body_teaser @@ -347,6 +351,7 @@ def validate_html_content(html_content: str) -> tuple[bool, str]: return False, "Content is empty" try: + html_content = wrap_html_fragment(html_content) extracted = trafilatura.extract(html_content) if not extracted: return False, "Invalid HTML structure or empty content" diff --git a/resolvers/editor.py b/resolvers/editor.py index 8bf65817..c9cd969c 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -23,6 +23,7 @@ from services.db import local_session from services.notify import notify_shout from services.schema import mutation, query from services.search import search_service +from utils.html_wrapper import wrap_html_fragment from utils.logger import root_logger as logger @@ -180,9 +181,11 @@ async def create_shout(_, info, inp): # Создаем публикацию без topics body = inp.get("body", "") lead = inp.get("lead", "") - body_text = trafilatura.extract(body) - lead_text = trafilatura.extract(lead) - seo = inp.get("seo", lead_text or body_text[:300].split(". ")[:-1].join(". ")) + body_html = wrap_html_fragment(body) + lead_html = wrap_html_fragment(lead) + body_text = trafilatura.extract(body_html) + lead_text = trafilatura.extract(lead_html) + seo = inp.get("seo", lead_text.strip() or body_text.strip()[:300].split(". ")[:-1].join(". ")) new_shout = Shout( slug=slug, body=body, diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/utils/html_wrapper.py b/utils/html_wrapper.py new file mode 100644 index 00000000..fb9e4ba4 --- /dev/null +++ b/utils/html_wrapper.py @@ -0,0 +1,38 @@ +""" +Модуль для обработки HTML-фрагментов +""" + +def wrap_html_fragment(fragment: str) -> str: + """ + Оборачивает HTML-фрагмент в полную HTML-структуру для корректной обработки. + + Args: + fragment: HTML-фрагмент для обработки + + Returns: + str: Полный HTML-документ + + Example: + >>> wrap_html_fragment("

Текст параграфа

") + '

Текст параграфа

' + """ + if not fragment or not fragment.strip(): + return fragment + + # Проверяем, является ли контент полным HTML-документом + is_full_html = fragment.strip().startswith(' + + + + + + +{fragment} + +""" + + return fragment \ No newline at end of file From d293819ad92792d0a7d6c78bc1b4d273cd47abdc Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 10:13:29 +0300 Subject: [PATCH 19/36] topic-commented-stat-fix --- resolvers/topic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resolvers/topic.py b/resolvers/topic.py index 4ecf241b..a91128fc 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -10,6 +10,7 @@ from cache.cache import ( ) from orm.author import Author from orm.topic import Topic +from orm.reaction import ReactionKind from resolvers.stat import get_with_stat from services.auth import login_required from services.db import local_session @@ -143,7 +144,7 @@ async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None) SELECT st.topic, COUNT(DISTINCT r.id) as comments_count FROM shout_topic st JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL - JOIN reaction r ON r.shout = s.id + JOIN reaction r ON r.shout = s.id AND r.kind = '{ReactionKind.COMMENT.value}' AND r.deleted_at IS NULL WHERE st.topic IN ({",".join(map(str, topic_ids))}) GROUP BY st.topic """ From b17acae0af96448a4214c4c09cedb8f191a3ae78 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 10:24:48 +0300 Subject: [PATCH 20/36] topic-commented-stat-fix2 --- resolvers/topic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/resolvers/topic.py b/resolvers/topic.py index a91128fc..15679ae5 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -145,6 +145,7 @@ async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None) FROM shout_topic st JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL JOIN reaction r ON r.shout = s.id AND r.kind = '{ReactionKind.COMMENT.value}' AND r.deleted_at IS NULL + JOIN author a ON r.created_by = a.id AND a.deleted_at IS NULL WHERE st.topic IN ({",".join(map(str, topic_ids))}) GROUP BY st.topic """ From 79e1f15a2eb325f59e4e86d08c211278cae93764 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 10:30:04 +0300 Subject: [PATCH 21/36] topic-commented-stat-fix3 --- resolvers/topic.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resolvers/topic.py b/resolvers/topic.py index 15679ae5..c3b61c65 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -113,7 +113,7 @@ async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None) 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 + JOIN shout s ON st.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL WHERE st.topic IN ({",".join(map(str, topic_ids))}) GROUP BY st.topic """ @@ -122,7 +122,8 @@ async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None) # Запрос на получение статистики по подписчикам для выбранных тем followers_stats_query = f""" SELECT topic, COUNT(DISTINCT follower) as followers_count - FROM topic_followers + FROM topic_followers tf + JOIN shout s ON tf.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL WHERE topic IN ({",".join(map(str, topic_ids))}) GROUP BY topic """ From ed71405082942d00e9d990b3a4300dc107af73cc Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 10:30:58 +0300 Subject: [PATCH 22/36] topic-commented-stat-fix4 --- resolvers/topic.py | 1 - 1 file changed, 1 deletion(-) diff --git a/resolvers/topic.py b/resolvers/topic.py index c3b61c65..000f6d07 100644 --- a/resolvers/topic.py +++ b/resolvers/topic.py @@ -123,7 +123,6 @@ async def get_topics_with_stats(limit=100, offset=0, community_id=None, by=None) followers_stats_query = f""" SELECT topic, COUNT(DISTINCT follower) as followers_count FROM topic_followers tf - JOIN shout s ON tf.shout = s.id AND s.deleted_at IS NULL AND s.published_at IS NOT NULL WHERE topic IN ({",".join(map(str, topic_ids))}) GROUP BY topic """ From f71fc7fde92aef4f215703f4ade48c97484ecbc4 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 11:10:18 +0300 Subject: [PATCH 23/36] draft-publication-info --- orm/draft.py | 14 +++++++++++++- resolvers/draft.py | 24 ++++++++++++++++++------ schema/type.graphql | 12 +++++++++--- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/orm/draft.py b/orm/draft.py index f3b1f100..71b85057 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import relationship from orm.author import Author from orm.topic import Topic from services.db import Base +from orm.shout import Shout class DraftTopic(Base): @@ -61,4 +62,15 @@ class Draft(Base): # Связь с Community (если нужна как объект, а не ID) # community = relationship("Community", foreign_keys=[community_id], lazy="joined") - # Пока оставляем community_id как ID \ No newline at end of file + # Пока оставляем community_id как ID + + # Связь с публикацией (один-к-одному или один-к-нулю) + # Загружается через joinedload в резолвере + publication = relationship( + "Shout", + primaryjoin="Draft.id == Shout.draft", + foreign_keys="Shout.draft", + uselist=False, + lazy="noload", # Не грузим по умолчанию, только через options + viewonly=True # Указываем, что это связь только для чтения + ) \ No newline at end of file diff --git a/resolvers/draft.py b/resolvers/draft.py index 57df9330..eac5d906 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -74,8 +74,8 @@ async def load_drafts(_, info): """ Загружает все черновики, доступные текущему пользователю. - Предварительно загружает связанные объекты (topics, authors), чтобы избежать - ошибок с отсоединенными объектами при сериализации. + Предварительно загружает связанные объекты (topics, authors, publication), + чтобы избежать ошибок с отсоединенными объектами при сериализации. Returns: dict: Список черновиков или сообщение об ошибке @@ -89,16 +89,17 @@ async def load_drafts(_, info): try: with local_session() as session: - # Предзагружаем authors и topics - drafts = ( + # Предзагружаем authors, topics и связанную publication + drafts_query = ( session.query(Draft) .options( joinedload(Draft.topics), - joinedload(Draft.authors) + joinedload(Draft.authors), + joinedload(Draft.publication) # Загружаем связанную публикацию ) .filter(Draft.authors.any(Author.id == author_id)) - .all() ) + drafts = drafts_query.all() # Преобразуем объекты в словари, пока они в контексте сессии drafts_data = [] @@ -106,6 +107,17 @@ async def load_drafts(_, info): draft_dict = draft.dict() draft_dict["topics"] = [topic.dict() for topic in draft.topics] draft_dict["authors"] = [author.dict() for author in draft.authors] + + # Добавляем информацию о публикации, если она есть + if draft.publication: + draft_dict["publication"] = { + "id": draft.publication.id, + "slug": draft.publication.slug, + "published_at": draft.publication.published_at + } + else: + draft_dict["publication"] = None + drafts_data.append(draft_dict) return {"drafts": drafts_data} diff --git a/schema/type.graphql b/schema/type.graphql index d902d143..d1656826 100644 --- a/schema/type.graphql +++ b/schema/type.graphql @@ -107,6 +107,12 @@ type Shout { score: Float } +type PublicationInfo { + id: Int! + slug: String! + published_at: Int +} + type Draft { id: Int! created_at: Int! @@ -129,9 +135,9 @@ type Draft { deleted_at: Int updated_by: Author deleted_by: Author - authors: [Author] - topics: [Topic] - + authors: [Author]! + topics: [Topic]! + publication: PublicationInfo } type Stat { From 5f3d90fc9046924d8045bd9f29d59dae78c4f2b1 Mon Sep 17 00:00:00 2001 From: Untone Date: Mon, 28 Apr 2025 16:24:08 +0300 Subject: [PATCH 24/36] draft-publication-debug --- resolvers/draft.py | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index eac5d906..593e564b 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -1,8 +1,5 @@ import time -from operator import or_ - import trafilatura -from sqlalchemy.sql import and_ from sqlalchemy.orm import joinedload from cache.cache import ( @@ -385,7 +382,10 @@ async def publish_draft(_, info, draft_id: int): draft_id: ID черновика для публикации Returns: - dict: Опубликованная публикация и черновик или сообщение об ошибке + dict: Содержит одно из полей: + - error: Сообщение об ошибке, если публикация не удалась + - shout: Опубликованный объект Shout + - draft: Черновик (передается в ответе для совместимости с GraphQL схемой) """ user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) @@ -488,13 +488,39 @@ async def publish_draft(_, info, draft_id: int): # Для уже опубликованных материалов просто отправляем уведомление об обновлении await notify_shout(shout.dict(), "update") - session.commit() - shout_dict = shout.dict() - # Добавляем объект автора в updated_by - shout_dict["updated_by"] = author_dict - return {"shout": shout_dict} + try: + # Фиксируем изменения + session.commit() + + # После коммита преобразуем в словари для ответа + try: + # Важно: для GraphQL схемы возвращаем как shout, так и draft + # (поскольку в CommonResult определены оба поля) + shout_dict = shout.dict() + draft_dict = draft.dict() + + # Логирование для отладки + logger.info(f"Successfully published shout #{shout.id} from draft #{draft.id}") + logger.debug(f"Shout data: {shout_dict}") + + # Важно: возвращаем draft для CommonResult.draft и shout для CommonResult.shout + return { + "shout": shout_dict, + "draft": draft_dict, + "error": None + } + except Exception as serialize_error: + # Если случилась ошибка при сериализации + logger.error(f"Error serializing result: {serialize_error}", exc_info=True) + return {"error": f"Published successfully but failed to return result: {str(serialize_error)}"} + except Exception as commit_error: + # Ошибка при коммите + session.rollback() + logger.error(f"Commit error: {commit_error}", exc_info=True) + return {"error": f"Failed to save changes: {str(commit_error)}"} except Exception as e: + # Общая ошибка обработки logger.error(f"Failed to publish shout: {e}", exc_info=True) if "session" in locals(): session.rollback() From 58ec60262bba85deebcdeb28637184dd2813e8ae Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 10:53:40 +0300 Subject: [PATCH 25/36] unpublish,delete-draft-fix --- resolvers/draft.py | 2 +- resolvers/editor.py | 39 +++++++++++++++++++++------------------ 2 files changed, 22 insertions(+), 19 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index 593e564b..e52965c7 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -327,7 +327,7 @@ async def delete_draft(_, info, draft_id: int): draft = session.query(Draft).filter(Draft.id == draft_id).first() if not draft: return {"error": "Draft not found"} - if author_id != draft.created_by_id and draft.authors.filter(Author.id == author_id).count() == 0: + if author_id != draft.created_by.id and draft.authors.filter(Author.id == author_id).count() == 0: return {"error": "You are not allowed to delete this draft"} session.delete(draft) session.commit() diff --git a/resolvers/editor.py b/resolvers/editor.py index c9cd969c..a38f2575 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -3,7 +3,7 @@ import time import orjson import trafilatura from sqlalchemy import and_, desc, select -from sqlalchemy.orm import joinedload +from sqlalchemy.orm import joinedload, selectinload from sqlalchemy.sql.functions import coalesce from cache.cache import ( @@ -711,26 +711,29 @@ async def unpublish_shout(_, info, shout_id: int): shout = None with local_session() as session: try: - # Загружаем Shout с предзагрузкой draft и его связей authors/topics - # Используем selectinload для коллекций authors/topics внутри draft - - # это может быть эффективнее joinedload, если draft один. - shout = ( - session.query(Shout) - .options( - joinedload(Shout.draft) # Загружаем сам черновик - .selectinload(Draft.authors), # Загружаем авторов черновика через отдельный запрос - joinedload(Shout.draft) - .selectinload(Draft.topics) # Загружаем темы черновика через отдельный запрос - # Также предзагружаем авторов самой публикации, если они нужны для проверки прав или возврата - # selectinload(Shout.authors) - ) - .filter(Shout.id == shout_id) - .first() - ) + # Сначала загружаем Shout без связей + shout = session.query(Shout).filter(Shout.id == shout_id).first() if not shout: logger.warning(f"Shout not found for unpublish: ID {shout_id}") return {"error": "Shout not found"} + + # Если у публикации есть связанный черновик, загружаем его с relationships + if shout.draft: + # Отдельно загружаем черновик с его связями + draft = ( + session.query(Draft) + .options( + selectinload(Draft.authors), + selectinload(Draft.topics) + ) + .filter(Draft.id == shout.draft) + .first() + ) + + # Связываем черновик с публикацией вручную для доступа через API + if draft: + shout.draft_obj = draft # TODO: Добавить проверку прав доступа, если необходимо # if author_id not in [a.id for a in shout.authors]: # Требует selectinload(Shout.authors) выше @@ -754,7 +757,7 @@ async def unpublish_shout(_, info, shout_id: int): except Exception as e: session.rollback() logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True) - return {"error": "Failed to unpublish shout"} + return {"error": f"Failed to unpublish shout: {str(e)}"} # Возвращаем объект shout с предзагруженным draft и его связями logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}") From 44852a1553690edd8c3856ec54a32db870e304f6 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 10:56:34 +0300 Subject: [PATCH 26/36] delete-draft-fix2 --- resolvers/draft.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index e52965c7..f2bda62a 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -327,7 +327,7 @@ async def delete_draft(_, info, draft_id: int): draft = session.query(Draft).filter(Draft.id == draft_id).first() if not draft: return {"error": "Draft not found"} - if author_id != draft.created_by.id and draft.authors.filter(Author.id == author_id).count() == 0: + if author_id != draft.created_by and draft.authors.filter(Author.id == author_id).count() == 0: return {"error": "You are not allowed to delete this draft"} session.delete(draft) session.commit() From 4f1eab513a6890544b216a39a7e8792219c7b262 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 10:57:55 +0300 Subject: [PATCH 27/36] unpublish-fix2 --- resolvers/editor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index a38f2575..cb80aa7c 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -745,9 +745,7 @@ async def unpublish_shout(_, info, shout_id: int): # Инвалидация кэша try: - # Передаем slug или ID, если slug нет - cache_key = shout.slug if shout.slug else shout.id - await invalidate_shout_related_cache(cache_key) + await invalidate_shout_related_cache(shout.id, author_id) await invalidate_shouts_cache() logger.info(f"Cache invalidated after unpublishing shout {shout_id}") except Exception as cache_err: From 3fbd2e677acd3b068cf1a2dc13b2f86b7a1ed285 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:00:19 +0300 Subject: [PATCH 28/36] unpublish-fix3 --- resolvers/editor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index cb80aa7c..92bafc4b 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -745,7 +745,7 @@ async def unpublish_shout(_, info, shout_id: int): # Инвалидация кэша try: - await invalidate_shout_related_cache(shout.id, author_id) + await invalidate_shout_related_cache(shout, author_id) await invalidate_shouts_cache() logger.info(f"Cache invalidated after unpublishing shout {shout_id}") except Exception as cache_err: From d6202561a9c1a279e00519e8f00d1bd3f8523a65 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:07:03 +0300 Subject: [PATCH 29/36] unpublish-fix4 --- resolvers/editor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index 92bafc4b..94c08063 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -745,8 +745,14 @@ async def unpublish_shout(_, info, shout_id: int): # Инвалидация кэша try: + cache_keys = [ + "feed", # лента + f"author_{author_id}", # публикации автора + "random_top", # случайные топовые + "unrated", # неоцененные + ] await invalidate_shout_related_cache(shout, author_id) - await invalidate_shouts_cache() + await invalidate_shouts_cache(cache_keys) logger.info(f"Cache invalidated after unpublishing shout {shout_id}") except Exception as cache_err: logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}") From 785548d055a1987fe1d31674b7cf2f33d2eddd47 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:11:14 +0300 Subject: [PATCH 30/36] cache-revalidation-fix --- CHANGELOG.md | 7 +++++++ cache/revalidator.py | 15 +++++++++++++++ docs/caching.md | 22 +++++++++++++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d5d57ad..6aa3a7f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +#### [0.4.20] - 2025-05-03 +- Исправлена ошибка в классе `CacheRevalidationManager`: добавлена инициализация атрибута `_redis` +- Улучшена обработка соединения с Redis в менеджере ревалидации кэша: + - Автоматическое восстановление соединения в случае его потери + - Проверка соединения перед выполнением операций с кэшем + - Дополнительное логирование для упрощения диагностики проблем + #### [0.4.19] - 2025-04-14 - dropped `Shout.description` and `Draft.description` to be UX-generated - use redis to init views counters after migrator diff --git a/cache/revalidator.py b/cache/revalidator.py index 7be5041c..ac0c1ba1 100644 --- a/cache/revalidator.py +++ b/cache/revalidator.py @@ -8,6 +8,7 @@ from cache.cache import ( invalidate_cache_by_prefix, ) from resolvers.stat import get_with_stat +from services.redis import redis from utils.logger import root_logger as logger CACHE_REVALIDATION_INTERVAL = 300 # 5 minutes @@ -21,9 +22,19 @@ class CacheRevalidationManager: self.lock = asyncio.Lock() self.running = True self.MAX_BATCH_SIZE = 10 # Максимальное количество элементов для поштучной обработки + self._redis = redis # Добавлена инициализация _redis для доступа к Redis-клиенту async def start(self): """Запуск фонового воркера для ревалидации кэша.""" + # Проверяем, что у нас есть соединение с Redis + if not self._redis._client: + logger.warning("Redis connection not established. Waiting for connection...") + try: + await self._redis.connect() + logger.info("Redis connection established for revalidation manager") + except Exception as e: + logger.error(f"Failed to connect to Redis: {e}") + self.task = asyncio.create_task(self.revalidate_cache()) async def revalidate_cache(self): @@ -39,6 +50,10 @@ class CacheRevalidationManager: async def process_revalidation(self): """Обновление кэша для всех сущностей, требующих ревалидации.""" + # Проверяем соединение с Redis + if not self._redis._client: + return # Выходим из метода, если не удалось подключиться + async with self.lock: # Ревалидация кэша авторов if self.items_to_revalidate["authors"]: diff --git a/docs/caching.md b/docs/caching.md index 7c025be2..0a179764 100644 --- a/docs/caching.md +++ b/docs/caching.md @@ -147,16 +147,32 @@ await invalidate_topics_cache(456) ```python class CacheRevalidationManager: - # ... - async def process_revalidation(self): + def __init__(self, interval=CACHE_REVALIDATION_INTERVAL): # ... + self._redis = redis # Прямая ссылка на сервис Redis + + async def start(self): + # Проверка и установка соединения с Redis + # ... + + async def process_revalidation(self): + # Обработка элементов для ревалидации + # ... + def mark_for_revalidation(self, entity_id, entity_type): + # Добавляет сущность в очередь на ревалидацию # ... ``` Менеджер ревалидации работает как асинхронный фоновый процесс, который периодически (по умолчанию каждые 5 минут) проверяет наличие сущностей для ревалидации. -Особенности реализации: +**Взаимодействие с Redis:** +- CacheRevalidationManager хранит прямую ссылку на сервис Redis через атрибут `_redis` +- При запуске проверяется наличие соединения с Redis и при необходимости устанавливается новое +- Включена автоматическая проверка соединения перед каждой операцией ревалидации +- Система самостоятельно восстанавливает соединение при его потере + +**Особенности реализации:** - Для авторов и тем используется поштучная ревалидация каждой записи - Для шаутов и реакций используется батчевая обработка, с порогом в 10 элементов - При достижении порога система переключается на инвалидацию коллекций вместо поштучной обработки From 96afda77a67ae09cd12d1be34d75d0845c3abedb Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:35:03 +0300 Subject: [PATCH 31/36] unpublish-fix5 --- CHANGELOG.md | 4 ++++ resolvers/editor.py | 55 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6aa3a7f3..90589770 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ - Автоматическое восстановление соединения в случае его потери - Проверка соединения перед выполнением операций с кэшем - Дополнительное логирование для упрощения диагностики проблем +- Исправлен резолвер `unpublish_shout`: + - Корректное формирование синтетического поля `publication` с `published_at: null` + - Возвращение полноценного словаря с данными вместо объекта модели + - Улучшена загрузка связанных данных (авторы, темы) для правильного формирования ответа #### [0.4.19] - 2025-04-14 - dropped `Shout.description` and `Draft.description` to be UX-generated diff --git a/resolvers/editor.py b/resolvers/editor.py index 94c08063..79108b1c 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -711,8 +711,16 @@ async def unpublish_shout(_, info, shout_id: int): shout = None with local_session() as session: try: - # Сначала загружаем Shout без связей - shout = session.query(Shout).filter(Shout.id == shout_id).first() + # Загружаем Shout со всеми связями для правильного формирования ответа + shout = ( + session.query(Shout) + .options( + joinedload(Shout.authors), + joinedload(Shout.topics).joinedload(ShoutTopic.topic) + ) + .filter(Shout.id == shout_id) + .first() + ) if not shout: logger.warning(f"Shout not found for unpublish: ID {shout_id}") @@ -740,8 +748,46 @@ async def unpublish_shout(_, info, shout_id: int): # logger.warning(f"Author {author_id} denied unpublishing shout {shout_id}") # return {"error": "Access denied"} + # Запоминаем старый slug и id для формирования поля publication + shout_slug = shout.slug + shout_id_for_publication = shout.id + + # Снимаем с публикации (устанавливаем published_at в None) shout.published_at = None session.commit() + + # Формируем полноценный словарь для ответа + shout_dict = shout.dict() + + # Добавляем связанные данные + shout_dict["topics"] = ( + [ + {"id": topic.topic.id, "slug": topic.topic.slug, "title": topic.topic.title} + for topic in shout.topics if topic.topic + ] + if shout.topics + else [] + ) + + # Добавляем main_topic + shout_dict["main_topic"] = get_main_topic(shout.topics) + + # Добавляем авторов + shout_dict["authors"] = ( + [ + {"id": author.id, "name": author.name, "slug": author.slug} + for author in shout.authors + ] + if shout.authors + else [] + ) + + # Важно! Обновляем поле publication, отражая состояние "снят с публикации" + shout_dict["publication"] = { + "id": shout_id_for_publication, + "slug": shout_slug, + "published_at": None # Ключевое изменение - устанавливаем published_at в None + } # Инвалидация кэша try: @@ -757,12 +803,11 @@ async def unpublish_shout(_, info, shout_id: int): except Exception as cache_err: logger.error(f"Failed to invalidate cache for unpublish shout {shout_id}: {cache_err}") - except Exception as e: session.rollback() logger.error(f"Failed to unpublish shout {shout_id}: {e}", exc_info=True) return {"error": f"Failed to unpublish shout: {str(e)}"} - # Возвращаем объект shout с предзагруженным draft и его связями + # Возвращаем сформированный словарь вместо объекта logger.info(f"Shout {shout_id} unpublished successfully by author {author_id}") - return {"shout": shout} \ No newline at end of file + return {"shout": shout_dict} \ No newline at end of file From d2a8c2307609e69ef7b9aadb3196a94c08d03998 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:47:35 +0300 Subject: [PATCH 32/36] unpublish-fix5 --- resolvers/editor.py | 47 +++++++++++---------------------------------- 1 file changed, 11 insertions(+), 36 deletions(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index 79108b1c..eb3d25e9 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -649,44 +649,19 @@ def get_main_topic(topics): """Get the main topic from a list of ShoutTopic objects.""" logger.info(f"Starting get_main_topic with {len(topics) if topics else 0} topics") logger.debug( - f"Topics data: {[(t.topic.slug if t.topic else 'no-topic', t.main) for t in topics] if topics else []}" + f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}" ) - if not topics: logger.warning("No topics provided to get_main_topic") - return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True} - - # Find first main topic in original order - main_topic_rel = next((st for st in topics if st.main), None) - logger.debug( - f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}" - ) - - if main_topic_rel and main_topic_rel.topic: - result = { - "slug": main_topic_rel.topic.slug, - "title": main_topic_rel.topic.title, - "id": main_topic_rel.topic.id, + return + else: + logger.info(f"Using first topic as main: {topics[0].slug}") + return { + "slug": topics[0].slug, + "title": topics[0].title, + "id": topics[0].id, "is_main": True, } - logger.info(f"Returning main topic: {result}") - return result - - # If no main found but topics exist, return first - if topics and topics[0].topic: - logger.info(f"No main topic found, using first topic: {topics[0].topic.slug}") - result = { - "slug": topics[0].topic.slug, - "title": topics[0].topic.title, - "id": topics[0].topic.id, - "is_main": True, - } - return result - - logger.warning("No valid topics found, returning default") - return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True} - - @mutation.field("unpublish_shout") @login_required @@ -716,7 +691,7 @@ async def unpublish_shout(_, info, shout_id: int): session.query(Shout) .options( joinedload(Shout.authors), - joinedload(Shout.topics).joinedload(ShoutTopic.topic) + selectinload(Shout.topics) ) .filter(Shout.id == shout_id) .first() @@ -762,8 +737,8 @@ async def unpublish_shout(_, info, shout_id: int): # Добавляем связанные данные shout_dict["topics"] = ( [ - {"id": topic.topic.id, "slug": topic.topic.slug, "title": topic.topic.title} - for topic in shout.topics if topic.topic + {"id": topic.id, "slug": topic.slug, "title": topic.title} + for topic in shout.topics ] if shout.topics else [] From 32cb810f512c8861b2a924f494f6a7c3858b498f Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:52:10 +0300 Subject: [PATCH 33/36] unpublish-fix7 --- resolvers/editor.py | 58 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index eb3d25e9..95361c20 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -651,17 +651,55 @@ def get_main_topic(topics): logger.debug( f"Topics data: {[(t.slug, getattr(t, 'main', False)) for t in topics] if topics else []}" ) + if not topics: logger.warning("No topics provided to get_main_topic") - return + return {"id": 0, "title": "no topic", "slug": "notopic", "is_main": True} + + # Проверяем, является ли topics списком объектов ShoutTopic или Topic + if hasattr(topics[0], 'topic') and topics[0].topic: + # Для ShoutTopic объектов (старый формат) + # Find first main topic in original order + main_topic_rel = next((st for st in topics if getattr(st, 'main', False)), None) + logger.debug( + f"Found main topic relation: {main_topic_rel.topic.slug if main_topic_rel and main_topic_rel.topic else None}" + ) + + if main_topic_rel and main_topic_rel.topic: + result = { + "slug": main_topic_rel.topic.slug, + "title": main_topic_rel.topic.title, + "id": main_topic_rel.topic.id, + "is_main": True, + } + logger.info(f"Returning main topic: {result}") + return result + + # If no main found but topics exist, return first + if topics and topics[0].topic: + logger.info(f"No main topic found, using first topic: {topics[0].topic.slug}") + result = { + "slug": topics[0].topic.slug, + "title": topics[0].topic.title, + "id": topics[0].topic.id, + "is_main": True, + } + return result else: - logger.info(f"Using first topic as main: {topics[0].slug}") - return { - "slug": topics[0].slug, - "title": topics[0].title, - "id": topics[0].id, - "is_main": True, - } + # Для Topic объектов (новый формат из selectinload) + # После смены на selectinload у нас просто список Topic объектов + if topics: + logger.info(f"Using first topic as main: {topics[0].slug}") + result = { + "slug": topics[0].slug, + "title": topics[0].title, + "id": topics[0].id, + "is_main": True, + } + return result + + logger.warning("No valid topics found, returning default") + return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True} @mutation.field("unpublish_shout") @login_required @@ -745,7 +783,9 @@ async def unpublish_shout(_, info, shout_id: int): ) # Добавляем main_topic - shout_dict["main_topic"] = get_main_topic(shout.topics) + main_topic = get_main_topic(shout.topics) + if main_topic["id"]: + shout_dict["main_topic"] = main_topic # Добавляем авторов shout_dict["authors"] = ( From 2b7d5a25b528b78615792eb78d2cdf18494f6a6b Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 3 May 2025 11:52:14 +0300 Subject: [PATCH 34/36] unpublish-fix7 --- resolvers/editor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/resolvers/editor.py b/resolvers/editor.py index 95361c20..0fd67c6e 100644 --- a/resolvers/editor.py +++ b/resolvers/editor.py @@ -783,9 +783,7 @@ async def unpublish_shout(_, info, shout_id: int): ) # Добавляем main_topic - main_topic = get_main_topic(shout.topics) - if main_topic["id"]: - shout_dict["main_topic"] = main_topic + shout_dict["main_topic"] = get_main_topic(shout.topics) # Добавляем авторов shout_dict["authors"] = ( From 51de649686153f118401b0d1dbac98d7cde9a481 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 7 May 2025 10:22:30 +0300 Subject: [PATCH 35/36] draft-topics-fix --- resolvers/draft.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/resolvers/draft.py b/resolvers/draft.py index f2bda62a..07b7e365 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -46,22 +46,27 @@ def create_shout_from_draft(session, draft, author_id): """ # Создаем новую публикацию shout = Shout( - body=draft.body, + body=draft.body or "", slug=draft.slug, cover=draft.cover, cover_caption=draft.cover_caption, lead=draft.lead, - title=draft.title, + title=draft.title or "", subtitle=draft.subtitle, - layout=draft.layout, - media=draft.media, - lang=draft.lang, + layout=draft.layout or "article", + media=draft.media or [], + lang=draft.lang or "ru", seo=draft.seo, created_by=author_id, community=draft.community, draft=draft.id, deleted_at=None, ) + + # Инициализируем пустые массивы для связей + shout.topics = [] + shout.authors = [] + return shout @@ -456,6 +461,15 @@ async def publish_draft(_, info, draft_id: int): main=topic.main if hasattr(topic, "main") else False ) session.add(st) + + # Загружаем темы для шаута после создания связей + shout.topics = [ + session.query(Topic).filter(Topic.id == topic.id).first() + for topic in draft.topics + ] + else: + # Инициализируем пустой список тем если их нет + shout.topics = [] # Обновляем черновик draft.updated_at = now From a6b3b218940aa79e3f669ebba99cb4c06cf536b3 Mon Sep 17 00:00:00 2001 From: Untone Date: Wed, 7 May 2025 10:37:18 +0300 Subject: [PATCH 36/36] draft-topics-fix2 --- orm/draft.py | 32 +++++++- resolvers/draft.py | 192 +++++++++++++++------------------------------ 2 files changed, 94 insertions(+), 130 deletions(-) diff --git a/orm/draft.py b/orm/draft.py index 71b85057..0eedd70e 100644 --- a/orm/draft.py +++ b/orm/draft.py @@ -73,4 +73,34 @@ class Draft(Base): uselist=False, lazy="noload", # Не грузим по умолчанию, только через options viewonly=True # Указываем, что это связь только для чтения - ) \ No newline at end of file + ) + + def dict(self): + """ + Сериализует объект Draft в словарь. + Гарантирует, что поля topics и authors всегда будут списками. + """ + return { + "id": self.id, + "created_at": self.created_at, + "created_by": self.created_by, + "community": self.community, + "layout": self.layout, + "slug": self.slug, + "title": self.title, + "subtitle": self.subtitle, + "lead": self.lead, + "body": self.body, + "media": self.media or [], + "cover": self.cover, + "cover_caption": self.cover_caption, + "lang": self.lang, + "seo": self.seo, + "updated_at": self.updated_at, + "deleted_at": self.deleted_at, + "updated_by": self.updated_by, + "deleted_by": self.deleted_by, + # Гарантируем, что topics и authors всегда будут списками + "topics": [topic.dict() for topic in (self.topics or [])], + "authors": [author.dict() for author in (self.authors or [])] + } \ No newline at end of file diff --git a/resolvers/draft.py b/resolvers/draft.py index 07b7e365..a33a0546 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -107,8 +107,9 @@ async def load_drafts(_, info): drafts_data = [] for draft in drafts: draft_dict = draft.dict() - draft_dict["topics"] = [topic.dict() for topic in draft.topics] - draft_dict["authors"] = [author.dict() for author in draft.authors] + # Всегда возвращаем массив для topics, даже если он пустой + draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])] + draft_dict["authors"] = [author.dict() for author in (draft.authors or [])] # Добавляем информацию о публикации, если она есть if draft.publication: @@ -378,32 +379,36 @@ def validate_html_content(html_content: str) -> tuple[bool, str]: @mutation.field("publish_draft") @login_required async def publish_draft(_, info, draft_id: int): - """Публикует черновик в виде публикации (shout). - - Загружает связанные объекты (topics, authors) заранее, чтобы избежать ошибок - с отсоединенными объектами при сериализации. + """ + Публикует черновик, создавая новый Shout или обновляя существующий. Args: - draft_id: ID черновика для публикации + draft_id (int): ID черновика для публикации Returns: - dict: Содержит одно из полей: - - error: Сообщение об ошибке, если публикация не удалась - - shout: Опубликованный объект Shout - - draft: Черновик (передается в ответе для совместимости с GraphQL схемой) + dict: Результат публикации с shout или сообщением об ошибке """ user_id = info.context.get("user_id") author_dict = info.context.get("author", {}) author_id = author_dict.get("id") + if not user_id or not author_id: - return {"error": "User ID and author ID are required"} - - now = int(time.time()) - + return {"error": "Author ID is required"} + try: with local_session() as session: - # Сначала находим черновик - draft = session.query(Draft).filter(Draft.id == draft_id).first() + # Загружаем черновик со всеми связями + draft = ( + session.query(Draft) + .options( + joinedload(Draft.topics), + joinedload(Draft.authors), + joinedload(Draft.publication) + ) + .filter(Draft.id == draft_id) + .first() + ) + if not draft: return {"error": "Draft not found"} @@ -412,130 +417,59 @@ async def publish_draft(_, info, draft_id: int): if not is_valid: return {"error": f"Cannot publish draft: {error}"} - # Ищем существующий shout для этого черновика - shout = session.query(Shout).filter(Shout.draft == draft_id).first() - was_published = shout.published_at if shout else None - - if not shout: - # Создаем новый shout если не существует - shout = create_shout_from_draft(session, draft, author_id) - shout.published_at = now - else: + # Проверяем, есть ли уже публикация для этого черновика + if draft.publication: + shout = draft.publication # Обновляем существующую публикацию - shout.draft = draft.id - shout.created_by = author_id - shout.title = draft.title - shout.subtitle = draft.subtitle - shout.body = draft.body - shout.cover = draft.cover - shout.cover_caption = draft.cover_caption - shout.lead = draft.lead - shout.layout = draft.layout - shout.media = draft.media - shout.lang = draft.lang - shout.seo = draft.seo - shout.updated_at = now - - # Устанавливаем published_at только если была ранее снята с публикации - if not was_published: - shout.published_at = now - - # Сохраняем shout перед созданием связей - session.add(shout) - session.flush() + for field in ["body", "title", "subtitle", "lead", "cover", "cover_caption", "media", "lang", "seo"]: + if hasattr(draft, field): + setattr(shout, field, getattr(draft, field)) + shout.updated_at = int(time.time()) + shout.updated_by = author_id + else: + # Создаем новую публикацию + shout = create_shout_from_draft(session, draft, author_id) + now = int(time.time()) + shout.created_at = now + shout.published_at = now + session.add(shout) + session.flush() # Получаем ID нового шаута # Очищаем существующие связи session.query(ShoutAuthor).filter(ShoutAuthor.shout == shout.id).delete() session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete() - # Добавляем автора - sa = ShoutAuthor(shout=shout.id, author=author_id) - session.add(sa) + # Добавляем авторов + for author in (draft.authors or []): + sa = ShoutAuthor(shout=shout.id, author=author.id) + session.add(sa) - # Добавляем темы если есть - if draft.topics: - for topic in draft.topics: - st = ShoutTopic( - topic=topic.id, - shout=shout.id, - main=topic.main if hasattr(topic, "main") else False - ) - session.add(st) - - # Загружаем темы для шаута после создания связей - shout.topics = [ - session.query(Topic).filter(Topic.id == topic.id).first() - for topic in draft.topics - ] - else: - # Инициализируем пустой список тем если их нет - shout.topics = [] + # Добавляем темы + for topic in (draft.topics or []): + st = ShoutTopic( + topic=topic.id, + shout=shout.id, + main=topic.main if hasattr(topic, "main") else False + ) + session.add(st) - # Обновляем черновик - draft.updated_at = now - session.add(draft) + session.commit() - # Инвалидируем кэш только если это новая публикация или была снята с публикации - if not was_published: - cache_keys = ["feed", f"author_{author_id}", "random_top", "unrated"] + # Инвалидируем кеш + invalidate_shouts_cache() + invalidate_shout_related_cache(shout.id) - # Добавляем ключи для тем - for topic in shout.topics: - cache_keys.append(f"topic_{topic.id}") - cache_keys.append(f"topic_shouts_{topic.id}") - await cache_by_id(Topic, topic.id, cache_topic) + # Уведомляем о публикации + await notify_shout(shout.id) - # Инвалидируем кэш - await invalidate_shouts_cache(cache_keys) - await invalidate_shout_related_cache(shout, author_id) + # Обновляем поисковый индекс + search_service.index_shout(shout) - # Обновляем кэш авторов - for author in shout.authors: - await cache_by_id(Author, author.id, cache_author) + logger.info(f"Successfully published shout #{shout.id} from draft #{draft_id}") + logger.debug(f"Shout data: {shout.dict()}") - # Отправляем уведомление о публикации - await notify_shout(shout.dict(), "published") - - # Обновляем поисковый индекс - search_service.index(shout) - else: - # Для уже опубликованных материалов просто отправляем уведомление об обновлении - await notify_shout(shout.dict(), "update") - - try: - # Фиксируем изменения - session.commit() - - # После коммита преобразуем в словари для ответа - try: - # Важно: для GraphQL схемы возвращаем как shout, так и draft - # (поскольку в CommonResult определены оба поля) - shout_dict = shout.dict() - draft_dict = draft.dict() - - # Логирование для отладки - logger.info(f"Successfully published shout #{shout.id} from draft #{draft.id}") - logger.debug(f"Shout data: {shout_dict}") - - # Важно: возвращаем draft для CommonResult.draft и shout для CommonResult.shout - return { - "shout": shout_dict, - "draft": draft_dict, - "error": None - } - except Exception as serialize_error: - # Если случилась ошибка при сериализации - logger.error(f"Error serializing result: {serialize_error}", exc_info=True) - return {"error": f"Published successfully but failed to return result: {str(serialize_error)}"} - except Exception as commit_error: - # Ошибка при коммите - session.rollback() - logger.error(f"Commit error: {commit_error}", exc_info=True) - return {"error": f"Failed to save changes: {str(commit_error)}"} + return {"shout": shout} except Exception as e: - # Общая ошибка обработки - logger.error(f"Failed to publish shout: {e}", exc_info=True) - if "session" in locals(): - session.rollback() - return {"error": f"Failed to publish shout: {str(e)}"} + logger.error(f"Failed to publish draft {draft_id}: {e}", exc_info=True) + return {"error": f"Failed to publish draft: {str(e)}"}