drafts schema restore + publish/unpublish fixes
All checks were successful
Deploy on push / deploy (push) Successful in 32s
All checks were successful
Deploy on push / deploy (push) Successful in 32s
This commit is contained in:
parent
a310d59432
commit
bdae2abe25
27
orm/draft.py
27
orm/draft.py
|
@ -26,12 +26,14 @@ class DraftAuthor(Base):
|
||||||
caption = Column(String, nullable=True, default="")
|
caption = Column(String, nullable=True, default="")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Draft(Base):
|
class Draft(Base):
|
||||||
__tablename__ = "draft"
|
__tablename__ = "draft"
|
||||||
# required
|
# required
|
||||||
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
created_at: int = Column(Integer, nullable=False, default=lambda: int(time.time()))
|
||||||
created_by: int = Column(ForeignKey("author.id"), nullable=False)
|
# Переименовываем колонки ID, чтобы избежать конфликта имен с relationship
|
||||||
community: int = Column(ForeignKey("community.id"), nullable=False, default=1)
|
created_by: int = Column("created_by", ForeignKey("author.id"), nullable=False)
|
||||||
|
community: int = Column("community", ForeignKey("community.id"), nullable=False, default=1)
|
||||||
|
|
||||||
# optional
|
# optional
|
||||||
layout: str = Column(String, nullable=True, default="article")
|
layout: str = Column(String, nullable=True, default="article")
|
||||||
|
@ -49,7 +51,20 @@ class Draft(Base):
|
||||||
# auto
|
# auto
|
||||||
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
updated_at: int | None = Column(Integer, nullable=True, index=True)
|
||||||
deleted_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)
|
# Переименовываем колонки ID
|
||||||
deleted_by: int | None = Column(ForeignKey("author.id"), nullable=True)
|
updated_by: int | None = Column("updated_by", ForeignKey("author.id"), nullable=True)
|
||||||
authors = relationship(Author, secondary="draft_author")
|
deleted_by: int | None = Column("deleted_by", ForeignKey("author.id"), nullable=True)
|
||||||
topics = relationship(Topic, secondary="draft_topic")
|
|
||||||
|
# --- 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
|
|
@ -16,9 +16,11 @@ from resolvers.draft import (
|
||||||
delete_draft,
|
delete_draft,
|
||||||
load_drafts,
|
load_drafts,
|
||||||
publish_draft,
|
publish_draft,
|
||||||
unpublish_draft,
|
|
||||||
update_draft,
|
update_draft,
|
||||||
)
|
)
|
||||||
|
from resolvers.editor import (
|
||||||
|
unpublish_shout,
|
||||||
|
)
|
||||||
from resolvers.feed import (
|
from resolvers.feed import (
|
||||||
load_shouts_coauthored,
|
load_shouts_coauthored,
|
||||||
load_shouts_discussed,
|
load_shouts_discussed,
|
||||||
|
|
|
@ -18,8 +18,8 @@ from orm.shout import Shout, ShoutAuthor, ShoutTopic
|
||||||
from orm.topic import Topic
|
from orm.topic import Topic
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.notify import notify_shout, notify_draft
|
from services.notify import notify_shout
|
||||||
from services.schema import mutation, query, type_draft
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
from services.search import search_service
|
||||||
from utils.logger import root_logger as logger
|
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"}
|
return {"error": "User ID and author ID are required"}
|
||||||
|
|
||||||
with local_session() as session:
|
with local_session() as session:
|
||||||
|
# Предзагружаем authors и topics, т.к. они lazy='select' в модели
|
||||||
|
# created_by, updated_by, deleted_by загрузятся автоматически (lazy='joined')
|
||||||
drafts = (
|
drafts = (
|
||||||
session.query(Draft)
|
session.query(Draft)
|
||||||
.options(
|
.options(
|
||||||
joinedload(Draft.topics),
|
joinedload(Draft.topics),
|
||||||
joinedload(Draft.authors)
|
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()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -264,149 +267,12 @@ async def publish_draft(_, info, draft_id: int):
|
||||||
author_id = author_dict.get("id")
|
author_id = author_dict.get("id")
|
||||||
if not user_id or not author_id:
|
if not user_id or not author_id:
|
||||||
return {"error": "User ID and author ID are required"}
|
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())
|
now = int(time.time())
|
||||||
if not user_id or not author_id:
|
|
||||||
return {"error": "User ID and author ID are required"}
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with local_session() as session:
|
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()
|
shout = session.query(Shout).filter(Shout.id == shout_id).first()
|
||||||
if not shout:
|
if not shout:
|
||||||
return {"error": "Shout not found"}
|
return {"error": "Shout not found"}
|
||||||
|
@ -496,109 +362,3 @@ async def publish_shout(_, info, shout_id: int):
|
||||||
if "session" in locals():
|
if "session" in locals():
|
||||||
session.rollback()
|
session.rollback()
|
||||||
return {"error": f"Failed to publish shout: {str(e)}"}
|
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 []
|
|
||||||
|
|
|
@ -20,7 +20,7 @@ from resolvers.stat import get_with_stat
|
||||||
from services.auth import login_required
|
from services.auth import login_required
|
||||||
from services.db import local_session
|
from services.db import local_session
|
||||||
from services.notify import notify_shout
|
from services.notify import notify_shout
|
||||||
from services.schema import query
|
from services.schema import mutation, query
|
||||||
from services.search import search_service
|
from services.search import search_service
|
||||||
from utils.logger import root_logger as logger
|
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")
|
logger.warning("No valid topics found, returning default")
|
||||||
return {"slug": "notopic", "title": "no topic", "id": 0, "is_main": True}
|
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}
|
Loading…
Reference in New Issue
Block a user