core/resolvers/draft.py

481 lines
18 KiB
Python
Raw Normal View History

2025-02-09 14:18:01 +00:00
import time
2025-04-26 07:16:55 +00:00
from sqlalchemy.orm import joinedload
2025-02-09 14:18:01 +00:00
2025-02-09 19:26:50 +00:00
from cache.cache import (
2025-02-11 09:00:35 +00:00
invalidate_shout_related_cache,
invalidate_shouts_cache,
2025-02-09 19:26:50 +00:00
)
2025-05-16 06:23:48 +00:00
from auth.orm import Author
2025-04-26 13:03:41 +00:00
from orm.draft import Draft, DraftAuthor, DraftTopic
2025-02-09 19:26:50 +00:00
from orm.shout import Shout, ShoutAuthor, ShoutTopic
2025-02-09 14:18:01 +00:00
from services.auth import login_required
from services.db import local_session
from services.notify import notify_shout
from services.schema import mutation, query
2025-02-09 19:26:50 +00:00
from services.search import search_service
2025-05-16 06:23:48 +00:00
from utils.extract_text import extract_text
2025-02-11 09:00:35 +00:00
from utils.logger import root_logger as logger
2025-02-09 14:18:01 +00:00
2025-05-16 06:23:48 +00:00
2025-02-10 15:04:08 +00:00
def create_shout_from_draft(session, draft, author_id):
2025-04-26 13:13:07 +00:00
"""
Создаёт новый объект публикации (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
"""
2025-02-10 15:04:08 +00:00
# Создаем новую публикацию
shout = Shout(
2025-05-07 07:22:30 +00:00
body=draft.body or "",
2025-02-10 15:04:08 +00:00
slug=draft.slug,
cover=draft.cover,
cover_caption=draft.cover_caption,
lead=draft.lead,
2025-05-07 07:22:30 +00:00
title=draft.title or "",
2025-02-10 15:04:08 +00:00
subtitle=draft.subtitle,
2025-05-07 07:22:30 +00:00
layout=draft.layout or "article",
media=draft.media or [],
lang=draft.lang or "ru",
2025-02-10 15:04:08 +00:00
seo=draft.seo,
created_by=author_id,
community=draft.community,
draft=draft.id,
deleted_at=None,
)
2025-05-16 06:23:48 +00:00
2025-05-07 07:22:30 +00:00
# Инициализируем пустые массивы для связей
shout.topics = []
shout.authors = []
2025-05-16 06:23:48 +00:00
2025-02-10 15:04:08 +00:00
return shout
2025-02-09 14:18:01 +00:00
@query.field("load_drafts")
@login_required
async def load_drafts(_, info):
2025-04-26 08:45:16 +00:00
"""
Загружает все черновики, доступные текущему пользователю.
2025-05-16 06:23:48 +00:00
2025-04-28 08:10:18 +00:00
Предварительно загружает связанные объекты (topics, authors, publication),
чтобы избежать ошибок с отсоединенными объектами при сериализации.
2025-05-16 06:23:48 +00:00
2025-04-26 08:45:16 +00:00
Returns:
dict: Список черновиков или сообщение об ошибке
"""
2025-02-09 14:18:01 +00:00
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"}
2025-04-26 12:57:51 +00:00
try:
with local_session() as session:
2025-04-28 08:10:18 +00:00
# Предзагружаем authors, topics и связанную publication
drafts_query = (
2025-04-26 12:57:51 +00:00
session.query(Draft)
.options(
joinedload(Draft.topics),
2025-04-28 08:10:18 +00:00
joinedload(Draft.authors),
2025-05-16 06:23:48 +00:00
joinedload(Draft.publication), # Загружаем связанную публикацию
2025-04-26 12:57:51 +00:00
)
2025-04-27 06:15:07 +00:00
.filter(Draft.authors.any(Author.id == author_id))
2025-04-26 08:45:16 +00:00
)
2025-04-28 08:10:18 +00:00
drafts = drafts_query.all()
2025-05-16 06:23:48 +00:00
2025-04-26 12:57:51 +00:00
# Преобразуем объекты в словари, пока они в контексте сессии
drafts_data = []
for draft in drafts:
draft_dict = draft.dict()
2025-05-07 07:37:18 +00:00
# Всегда возвращаем массив для topics, даже если он пустой
draft_dict["topics"] = [topic.dict() for topic in (draft.topics or [])]
draft_dict["authors"] = [author.dict() for author in (draft.authors or [])]
2025-05-16 06:23:48 +00:00
2025-04-28 08:10:18 +00:00
# Добавляем информацию о публикации, если она есть
if draft.publication:
draft_dict["publication"] = {
"id": draft.publication.id,
"slug": draft.publication.slug,
2025-05-16 06:23:48 +00:00
"published_at": draft.publication.published_at,
2025-04-28 08:10:18 +00:00
}
else:
2025-05-16 06:23:48 +00:00
draft_dict["publication"] = None
2025-04-26 12:57:51 +00:00
drafts_data.append(draft_dict)
2025-05-16 06:23:48 +00:00
2025-04-26 12:57:51 +00:00
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)}"}
2025-02-09 14:18:01 +00:00
@mutation.field("create_draft")
@login_required
2025-02-11 09:00:35 +00:00
async def create_draft(_, info, draft_input):
2025-02-12 18:59:05 +00:00
"""Create a new draft.
Args:
info: GraphQL context
draft_input (dict): Draft data including optional fields:
2025-02-27 13:16:41 +00:00
- title (str, required) - заголовок черновика
2025-02-12 19:34:57 +00:00
- body (str, required) - текст черновика
2025-02-12 18:59:05 +00:00
- slug (str)
- etc.
Returns:
dict: Contains either:
- draft: The created draft object
- error: Error message if creation failed
Example:
>>> async def test_create():
... context = {'user_id': '123', 'author': {'id': 1}}
... info = type('Info', (), {'context': context})()
... result = await create_draft(None, info, {'title': 'Test'})
... assert result.get('error') is None
... assert result['draft'].title == 'Test'
... return result
"""
2025-02-09 14:18:01 +00:00
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:
2025-02-12 18:59:05 +00:00
return {"error": "Author ID is required"}
2025-02-09 14:18:01 +00:00
2025-02-12 19:34:57 +00:00
# Проверяем обязательные поля
if "body" not in draft_input or not draft_input["body"]:
2025-02-18 21:21:51 +00:00
draft_input["body"] = "" # Пустая строка вместо NULL
2025-03-20 08:01:39 +00:00
2025-02-27 13:16:41 +00:00
if "title" not in draft_input or not draft_input["title"]:
draft_input["title"] = "" # Пустая строка вместо NULL
2025-04-15 17:16:01 +00:00
2025-04-10 19:51:07 +00:00
# Проверяем slug - он должен быть или не пустым, или не передаваться вообще
if "slug" in draft_input and (draft_input["slug"] is None or draft_input["slug"] == ""):
# При создании черновика удаляем пустой slug из входных данных
del draft_input["slug"]
2025-02-18 21:21:51 +00:00
2025-02-12 18:59:05 +00:00
try:
with local_session() as session:
# Remove id from input if present since it's auto-generated
if "id" in draft_input:
del draft_input["id"]
2025-05-16 06:23:48 +00:00
2025-04-26 12:50:20 +00:00
# Добавляем текущее время создания и ID автора
2025-02-12 19:34:57 +00:00
draft_input["created_at"] = int(time.time())
2025-04-26 12:50:20 +00:00
draft_input["created_by"] = author_id
draft = Draft(**draft_input)
2025-02-12 18:59:05 +00:00
session.add(draft)
2025-04-27 06:15:07 +00:00
session.flush()
2025-05-16 06:23:48 +00:00
2025-04-27 06:15:07 +00:00
# Добавляем создателя как автора
da = DraftAuthor(shout=draft.id, author=author_id)
session.add(da)
2025-05-16 06:23:48 +00:00
2025-02-12 18:59:05 +00:00
session.commit()
return {"draft": draft}
except Exception as e:
logger.error(f"Failed to create draft: {e}", exc_info=True)
return {"error": f"Failed to create draft: {str(e)}"}
2025-02-09 14:18:01 +00:00
2025-05-16 06:23:48 +00:00
2025-04-16 11:17:59 +00:00
def generate_teaser(body, limit=300):
2025-05-16 06:23:48 +00:00
body_text = extract_text(body)
2025-04-16 11:17:59 +00:00
body_teaser = ". ".join(body_text[:limit].split(". ")[:-1])
return body_teaser
2025-02-09 14:18:01 +00:00
@mutation.field("update_draft")
@login_required
2025-03-20 08:01:39 +00:00
async def update_draft(_, info, draft_id: int, draft_input):
"""Обновляет черновик публикации.
Args:
draft_id: ID черновика для обновления
2025-04-26 13:03:41 +00:00
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
2025-03-20 08:01:39 +00:00
Returns:
dict: Обновленный черновик или сообщение об ошибке
"""
2025-02-09 14:18:01 +00:00
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
2025-03-20 08:01:39 +00:00
2025-02-09 14:18:01 +00:00
if not user_id or not author_id:
2025-02-11 09:00:35 +00:00
return {"error": "Author ID are required"}
2025-02-09 14:18:01 +00:00
2025-04-26 13:03:41 +00:00
try:
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 = {
2025-05-16 06:23:48 +00:00
"layout",
"author_ids",
"topic_ids",
"main_topic_id",
"media",
"lead",
"subtitle",
"lang",
"seo",
"body",
"title",
"slug",
"cover",
"cover_caption",
2025-04-26 13:03:41 +00:00
}
filtered_input = {k: v for k, v in draft_input.items() if k in allowed_fields}
# Проверяем slug
if "slug" in filtered_input and not filtered_input["slug"]:
del filtered_input["slug"]
# Обновляем связи с авторами если переданы
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(
2025-05-16 06:23:48 +00:00
shout=draft_id,
2025-04-26 13:03:41 +00:00
topic=tid,
2025-05-16 06:23:48 +00:00
main=(tid == main_topic_id) if main_topic_id else False,
2025-04-26 13:03:41 +00:00
)
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)
2025-05-16 06:23:48 +00:00
2025-04-26 13:03:41 +00:00
try:
2025-05-16 06:23:48 +00:00
body_text = extract_text(body_src) if body_src else None
lead_text = extract_text(lead_src) if lead_src else None
2025-04-26 13:03:41 +00:00
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"Failed to generate SEO for draft {draft_id}: {e}")
2025-04-10 19:51:07 +00:00
2025-04-26 13:03:41 +00:00
# Обновляем основные поля черновика
for key, value in filtered_input.items():
setattr(draft, key, value)
2025-04-15 17:16:01 +00:00
2025-04-26 13:03:41 +00:00
# Обновляем метаданные
draft.updated_at = int(time.time())
draft.updated_by = author_id
2025-04-16 11:17:59 +00:00
2025-04-26 13:03:41 +00:00
session.commit()
2025-05-16 06:23:48 +00:00
2025-04-26 13:03:41 +00:00
# Преобразуем объект в словарь для ответа
draft_dict = draft.dict()
draft_dict["topics"] = [topic.dict() for topic in draft.topics]
draft_dict["authors"] = [author.dict() for author in draft.authors]
2025-04-26 20:46:07 +00:00
# Добавляем объект автора в updated_by
draft_dict["updated_by"] = author_dict
2025-05-16 06:23:48 +00:00
2025-04-26 13:03:41 +00:00
return {"draft": draft_dict}
2025-04-16 11:17:59 +00:00
2025-04-26 13:03:41 +00:00
except Exception as e:
logger.error(f"Failed to update draft: {e}", exc_info=True)
return {"error": f"Failed to update draft: {str(e)}"}
2025-02-09 14:18:01 +00:00
@mutation.field("delete_draft")
@login_required
async def delete_draft(_, info, draft_id: int):
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
with local_session() as session:
draft = session.query(Draft).filter(Draft.id == draft_id).first()
if not draft:
return {"error": "Draft not found"}
2025-05-03 07:56:34 +00:00
if author_id != draft.created_by and draft.authors.filter(Author.id == author_id).count() == 0:
2025-02-10 15:04:08 +00:00
return {"error": "You are not allowed to delete this draft"}
2025-02-09 14:18:01 +00:00
session.delete(draft)
session.commit()
return {"draft": draft}
2025-04-26 14:02:55 +00:00
def validate_html_content(html_content: str) -> tuple[bool, str]:
"""
Проверяет валидность HTML контента через trafilatura.
2025-05-16 06:23:48 +00:00
2025-04-26 14:02:55 +00:00
Args:
html_content: HTML строка для проверки
2025-05-16 06:23:48 +00:00
2025-04-26 14:02:55 +00:00
Returns:
tuple[bool, str]: (валидность, сообщение об ошибке)
2025-05-16 06:23:48 +00:00
2025-04-26 14:02:55 +00:00
Example:
>>> is_valid, error = validate_html_content("<p>Valid HTML</p>")
>>> 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"
2025-05-16 06:23:48 +00:00
2025-04-26 14:02:55 +00:00
try:
2025-05-16 06:23:48 +00:00
extracted = extract_text(html_content)
return bool(extracted), extracted or ""
2025-04-26 14:02:55 +00:00
except Exception as e:
logger.error(f"HTML validation error: {e}", exc_info=True)
return False, f"Invalid HTML content: {str(e)}"
2025-02-09 14:18:01 +00:00
@mutation.field("publish_draft")
@login_required
async def publish_draft(_, info, draft_id: int):
2025-05-07 07:37:18 +00:00
"""
Публикует черновик, создавая новый Shout или обновляя существующий.
2025-05-16 06:23:48 +00:00
2025-04-26 07:16:55 +00:00
Args:
2025-05-07 07:37:18 +00:00
draft_id (int): ID черновика для публикации
2025-05-16 06:23:48 +00:00
2025-04-26 07:16:55 +00:00
Returns:
2025-05-07 07:37:18 +00:00
dict: Результат публикации с shout или сообщением об ошибке
2025-04-26 07:16:55 +00:00
"""
2025-02-09 14:18:01 +00:00
user_id = info.context.get("user_id")
author_dict = info.context.get("author", {})
author_id = author_dict.get("id")
2025-05-07 07:37:18 +00:00
2025-02-09 14:18:01 +00:00
if not user_id or not author_id:
2025-05-07 07:37:18 +00:00
return {"error": "Author ID is required"}
2025-02-09 14:18:01 +00:00
try:
with local_session() as session:
2025-05-07 07:37:18 +00:00
# Загружаем черновик со всеми связями
draft = (
session.query(Draft)
2025-05-16 06:23:48 +00:00
.options(joinedload(Draft.topics), joinedload(Draft.authors), joinedload(Draft.publication))
2025-05-07 07:37:18 +00:00
.filter(Draft.id == draft_id)
.first()
)
2025-02-09 14:18:01 +00:00
if not draft:
2025-02-10 15:04:08 +00:00
return {"error": "Draft not found"}
2025-04-26 12:57:51 +00:00
2025-04-26 14:02:55 +00:00
# Проверка валидности HTML в body
is_valid, error = validate_html_content(draft.body)
if not is_valid:
return {"error": f"Cannot publish draft: {error}"}
2025-04-26 13:19:33 +00:00
2025-05-07 07:37:18 +00:00
# Проверяем, есть ли уже публикация для этого черновика
if draft.publication:
shout = draft.publication
# Обновляем существующую публикацию
2025-05-16 06:23:48 +00:00
for field in [
"body",
"title",
"subtitle",
"lead",
"cover",
"cover_caption",
"media",
"lang",
"seo",
]:
2025-05-07 07:37:18 +00:00
if hasattr(draft, field):
setattr(shout, field, getattr(draft, field))
shout.updated_at = int(time.time())
shout.updated_by = author_id
else:
# Создаем новую публикацию
2025-02-10 15:04:08 +00:00
shout = create_shout_from_draft(session, draft, author_id)
2025-05-07 07:37:18 +00:00
now = int(time.time())
shout.created_at = now
2025-04-26 13:13:07 +00:00
shout.published_at = now
2025-05-07 07:37:18 +00:00
session.add(shout)
session.flush() # Получаем ID нового шаута
2025-04-26 13:13:07 +00:00
# Очищаем существующие связи
session.query(ShoutAuthor).filter(ShoutAuthor.shout == shout.id).delete()
session.query(ShoutTopic).filter(ShoutTopic.shout == shout.id).delete()
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
# Добавляем авторов
2025-05-16 06:23:48 +00:00
for author in draft.authors or []:
2025-05-07 07:37:18 +00:00
sa = ShoutAuthor(shout=shout.id, author=author.id)
session.add(sa)
# Добавляем темы
2025-05-16 06:23:48 +00:00
for topic in draft.topics or []:
2025-05-07 07:37:18 +00:00
st = ShoutTopic(
2025-05-16 06:23:48 +00:00
topic=topic.id, shout=shout.id, main=topic.main if hasattr(topic, "main") else False
2025-05-07 07:37:18 +00:00
)
session.add(st)
2025-02-11 09:00:35 +00:00
2025-05-07 07:37:18 +00:00
session.commit()
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
# Инвалидируем кеш
invalidate_shouts_cache()
invalidate_shout_related_cache(shout.id)
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
# Уведомляем о публикации
await notify_shout(shout.id)
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
# Обновляем поисковый индекс
search_service.index_shout(shout)
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
logger.info(f"Successfully published shout #{shout.id} from draft #{draft_id}")
logger.debug(f"Shout data: {shout.dict()}")
2025-02-09 19:26:50 +00:00
2025-05-07 07:37:18 +00:00
return {"shout": shout}
2025-02-09 14:18:01 +00:00
except Exception as e:
2025-05-07 07:37:18 +00:00
logger.error(f"Failed to publish draft {draft_id}: {e}", exc_info=True)
return {"error": f"Failed to publish draft: {str(e)}"}