core/resolvers/draft.py

502 lines
20 KiB
Python
Raw Normal View History

2025-02-09 14:18:01 +00:00
import time
2025-03-20 08:01:39 +00:00
from operator import or_
2025-02-11 09:00:35 +00:00
2025-04-15 17:14:42 +00:00
import trafilatura
2025-04-15 17:16:01 +00:00
from sqlalchemy.sql import and_
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
cache_author,
cache_by_id,
cache_topic,
invalidate_shout_related_cache,
invalidate_shouts_cache,
2025-02-09 19:26:50 +00:00
)
2025-02-09 14:18:01 +00:00
from orm.author 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-11 09:00:35 +00:00
from orm.topic import Topic
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-04-27 09:53:49 +00:00
from utils.html_wrapper import wrap_html_fragment
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-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(
body=draft.body,
slug=draft.slug,
cover=draft.cover,
cover_caption=draft.cover_caption,
lead=draft.lead,
title=draft.title,
subtitle=draft.subtitle,
layout=draft.layout,
media=draft.media,
lang=draft.lang,
seo=draft.seo,
created_by=author_id,
community=draft.community,
draft=draft.id,
deleted_at=None,
)
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-04-28 08:10:18 +00:00
Предварительно загружает связанные объекты (topics, authors, publication),
чтобы избежать ошибок с отсоединенными объектами при сериализации.
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),
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-04-26 08:45:16 +00:00
2025-04-26 12:57:51 +00:00
# Преобразуем объекты в словари, пока они в контексте сессии
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]
2025-04-28 08:10:18 +00:00
# Добавляем информацию о публикации, если она есть
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
2025-04-26 12:57:51 +00:00
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)}"}
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-04-16 08:48:47 +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()
# Добавляем создателя как автора
da = DraftAuthor(shout=draft.id, author=author_id)
session.add(da)
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-04-16 11:17:59 +00:00
def generate_teaser(body, limit=300):
2025-04-27 09:53:49 +00:00
body_html = wrap_html_fragment(body)
body_text = trafilatura.extract(body_html, include_comments=False, include_tables=False)
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 = {
"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}
# Проверяем 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(
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)
2025-04-27 09:53:49 +00:00
body_html = wrap_html_fragment(body_src)
lead_html = wrap_html_fragment(lead_src)
2025-04-26 13:03:41 +00:00
try:
2025-04-27 09:53:49 +00:00
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
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()
# Преобразуем объект в словарь для ответа
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-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-04-26 12:47:44 +00:00
if author_id != draft.created_by_id 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.
Args:
html_content: HTML строка для проверки
Returns:
tuple[bool, str]: (валидность, сообщение об ошибке)
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"
try:
2025-04-27 09:53:49 +00:00
html_content = wrap_html_fragment(html_content)
2025-04-26 14:02:55 +00:00
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)}"
2025-02-09 14:18:01 +00:00
@mutation.field("publish_draft")
@login_required
async def publish_draft(_, info, draft_id: int):
2025-04-26 07:16:55 +00:00
"""Публикует черновик в виде публикации (shout).
Загружает связанные объекты (topics, authors) заранее, чтобы избежать ошибок
с отсоединенными объектами при сериализации.
Args:
draft_id: ID черновика для публикации
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 07:16:55 +00:00
2025-02-10 15:04:08 +00:00
now = int(time.time())
2025-02-09 14:18:01 +00:00
try:
with local_session() as session:
2025-04-26 12:57:51 +00:00
# Сначала находим черновик
draft = session.query(Draft).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-04-26 12:57:51 +00:00
# Ищем существующий shout для этого черновика
shout = session.query(Shout).filter(Shout.draft == draft_id).first()
was_published = shout.published_at if shout else None
2025-02-10 15:04:08 +00:00
2025-02-09 14:18:01 +00:00
if not shout:
2025-04-26 12:57:51 +00:00
# Создаем новый shout если не существует
2025-02-10 15:04:08 +00:00
shout = create_shout_from_draft(session, draft, author_id)
2025-04-26 13:13:07 +00:00
shout.published_at = now
2025-02-09 14:18:01 +00:00
else:
2025-02-09 19:26:50 +00:00
# Обновляем существующую публикацию
2025-02-09 14:18:01 +00:00
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
2025-02-10 15:04:08 +00:00
shout.updated_at = now
2025-04-26 13:13:07 +00:00
2025-02-11 21:39:25 +00:00
# Устанавливаем published_at только если была ранее снята с публикации
2025-02-10 15:04:08 +00:00
if not was_published:
shout.published_at = now
2025-02-11 09:00:35 +00:00
2025-04-26 13:13:07 +00:00
# Сохраняем shout перед созданием связей
session.add(shout)
session.flush()
# Очищаем существующие связи
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-04-26 13:13:07 +00:00
# Добавляем автора
sa = ShoutAuthor(shout=shout.id, author=author_id)
session.add(sa)
# Добавляем темы если есть
2025-02-09 19:26:50 +00:00
if draft.topics:
for topic in draft.topics:
st = ShoutTopic(
2025-04-26 13:13:07 +00:00
topic=topic.id,
shout=shout.id,
main=topic.main if hasattr(topic, "main") else False
2025-02-09 19:26:50 +00:00
)
session.add(st)
2025-04-26 13:13:07 +00:00
# Обновляем черновик
draft.updated_at = now
2025-02-09 14:18:01 +00:00
session.add(draft)
2025-02-09 19:26:50 +00:00
# Инвалидируем кэш только если это новая публикация или была снята с публикации
if not was_published:
2025-02-11 09:00:35 +00:00
cache_keys = ["feed", f"author_{author_id}", "random_top", "unrated"]
2025-02-09 19:26:50 +00:00
# Добавляем ключи для тем
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 invalidate_shouts_cache(cache_keys)
await invalidate_shout_related_cache(shout, author_id)
# Обновляем кэш авторов
for author in shout.authors:
await cache_by_id(Author, author.id, cache_author)
# Отправляем уведомление о публикации
await notify_shout(shout.dict(), "published")
# Обновляем поисковый индекс
search_service.index(shout)
else:
# Для уже опубликованных материалов просто отправляем уведомление об обновлении
await notify_shout(shout.dict(), "update")
2025-02-09 14:18:01 +00:00
session.commit()
2025-04-26 14:07:02 +00:00
shout_dict = shout.dict()
2025-04-26 20:46:07 +00:00
# Добавляем объект автора в updated_by
shout_dict["updated_by"] = author_dict
2025-04-26 14:07:02 +00:00
return {"shout": shout_dict}
2025-02-09 14:18:01 +00:00
except Exception as e:
2025-02-09 19:26:50 +00:00
logger.error(f"Failed to publish shout: {e}", exc_info=True)
2025-02-11 09:00:35 +00:00
if "session" in locals():
2025-02-09 19:26:50 +00:00
session.rollback()
return {"error": f"Failed to publish shout: {str(e)}"}