From bdae2abe253082417de981e55ee867025ea88184 Mon Sep 17 00:00:00 2001 From: Untone Date: Sat, 26 Apr 2025 13:11:12 +0300 Subject: [PATCH] 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