2025-02-09 14:18:01 +00:00
|
|
|
|
import time
|
2025-06-01 23:56:11 +00:00
|
|
|
|
from typing import Any
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
from graphql import GraphQLResolveInfo
|
|
|
|
|
from sqlalchemy.orm import Session, joinedload
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
2025-05-29 09:37:39 +00:00
|
|
|
|
from auth.orm import Author
|
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-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
|
2025-04-26 10:11:12 +00:00
|
|
|
|
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-06-01 23:56:11 +00:00
|
|
|
|
def create_shout_from_draft(session: Session | None, draft: Draft, author_id: int) -> Shout:
|
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
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def load_drafts(_: None, info: GraphQLResolveInfo) -> dict[str, Any]:
|
2025-04-26 08:45:16 +00:00
|
|
|
|
"""
|
|
|
|
|
Загружает все черновики, доступные текущему пользователю.
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
Предварительно загружает связанные объекты (topics, authors),
|
2025-04-28 08:10:18 +00:00
|
|
|
|
чтобы избежать ошибок с отсоединенными объектами при сериализации.
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
2025-04-26 08:45:16 +00:00
|
|
|
|
Returns:
|
|
|
|
|
dict: Список черновиков или сообщение об ошибке
|
|
|
|
|
"""
|
2025-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
author_id = author_dict.get("id")
|
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
if not author_id:
|
|
|
|
|
return {"error": "Author ID is required"}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
2025-04-26 12:57:51 +00:00
|
|
|
|
try:
|
|
|
|
|
with local_session() as session:
|
2025-06-01 23:56:11 +00:00
|
|
|
|
# Предзагружаем authors и topics
|
2025-04-28 08:10:18 +00:00
|
|
|
|
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-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-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)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"error": f"Failed to load drafts: {e!s}"}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mutation.field("create_draft")
|
|
|
|
|
@login_required
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def create_draft(_: None, info: GraphQLResolveInfo, draft_input: dict[str, Any]) -> dict[str, Any]:
|
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-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
author_id = author_dict.get("id")
|
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
if not author_id or not isinstance(author_id, int):
|
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
|
2025-06-01 23:56:11 +00:00
|
|
|
|
draft_input.pop("id", None)
|
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)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"error": f"Failed to create draft: {e!s}"}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
2025-05-16 06:23:48 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
def generate_teaser(body: str, limit: int = 300) -> str:
|
2025-05-16 06:23:48 +00:00
|
|
|
|
body_text = extract_text(body)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return ". ".join(body_text[:limit].split(". ")[:-1])
|
2025-04-16 11:17:59 +00:00
|
|
|
|
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
|
|
|
|
@mutation.field("update_draft")
|
|
|
|
|
@login_required
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def update_draft(_: None, info: GraphQLResolveInfo, draft_id: int, draft_input: dict[str, Any]) -> dict[str, Any]:
|
2025-03-20 08:01:39 +00:00
|
|
|
|
"""Обновляет черновик публикации.
|
|
|
|
|
|
|
|
|
|
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-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
author_id = author_dict.get("id")
|
2025-03-20 08:01:39 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
if not author_id or not isinstance(author_id, int):
|
|
|
|
|
return {"error": "Author ID is 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
|
|
|
|
# Обновляем метаданные
|
2025-06-01 23:56:11 +00:00
|
|
|
|
draft.updated_at = int(time.time()) # type: ignore[assignment]
|
|
|
|
|
draft.updated_by = author_id # type: ignore[assignment]
|
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)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"error": f"Failed to update draft: {e!s}"}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mutation.field("delete_draft")
|
|
|
|
|
@login_required
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def delete_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict[str, Any]:
|
2025-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
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)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return False, f"Invalid HTML content: {e!s}"
|
2025-04-26 14:02:55 +00:00
|
|
|
|
|
|
|
|
|
|
2025-02-09 14:18:01 +00:00
|
|
|
|
@mutation.field("publish_draft")
|
|
|
|
|
@login_required
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict[str, Any]:
|
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-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-02-09 14:18:01 +00:00
|
|
|
|
author_id = author_dict.get("id")
|
2025-05-07 07:37:18 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
if not author_id or not isinstance(author_id, int):
|
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
|
2025-06-01 23:56:11 +00:00
|
|
|
|
draft_body = str(draft.body) if draft.body else ""
|
|
|
|
|
is_valid, error = validate_html_content(draft_body)
|
2025-04-26 14:02:55 +00:00
|
|
|
|
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-06-01 23:56:11 +00:00
|
|
|
|
if hasattr(draft, "body"):
|
|
|
|
|
shout.body = draft.body
|
|
|
|
|
if hasattr(draft, "title"):
|
|
|
|
|
shout.title = draft.title
|
|
|
|
|
if hasattr(draft, "subtitle"):
|
|
|
|
|
shout.subtitle = draft.subtitle
|
|
|
|
|
if hasattr(draft, "lead"):
|
|
|
|
|
shout.lead = draft.lead
|
|
|
|
|
if hasattr(draft, "cover"):
|
|
|
|
|
shout.cover = draft.cover
|
|
|
|
|
if hasattr(draft, "cover_caption"):
|
|
|
|
|
shout.cover_caption = draft.cover_caption
|
|
|
|
|
if hasattr(draft, "media"):
|
|
|
|
|
shout.media = draft.media
|
|
|
|
|
if hasattr(draft, "lang"):
|
|
|
|
|
shout.lang = draft.lang
|
|
|
|
|
if hasattr(draft, "seo"):
|
|
|
|
|
shout.seo = draft.seo
|
2025-05-07 07:37:18 +00:00
|
|
|
|
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-29 09:37:39 +00:00
|
|
|
|
st = ShoutTopic(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
|
|
|
|
# Инвалидируем кеш
|
2025-05-29 09:37:39 +00:00
|
|
|
|
cache_keys = [
|
|
|
|
|
f"shouts:{shout.id}",
|
|
|
|
|
]
|
2025-05-21 15:29:46 +00:00
|
|
|
|
await invalidate_shouts_cache(cache_keys)
|
|
|
|
|
await invalidate_shout_related_cache(shout, author_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
|
|
|
|
# Обновляем поисковый индекс
|
2025-06-01 23:56:11 +00:00
|
|
|
|
await search_service.perform_index(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)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"error": f"Failed to publish draft: {e!s}"}
|
2025-05-22 01:34:30 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@mutation.field("unpublish_draft")
|
|
|
|
|
@login_required
|
2025-06-01 23:56:11 +00:00
|
|
|
|
async def unpublish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict[str, Any]:
|
2025-05-22 01:34:30 +00:00
|
|
|
|
"""
|
|
|
|
|
Снимает с публикации черновик, обновляя связанный Shout.
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
Args:
|
|
|
|
|
draft_id (int): ID черновика, публикацию которого нужно снять
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
Returns:
|
|
|
|
|
dict: Результат операции с информацией о черновике или сообщением об ошибке
|
|
|
|
|
"""
|
2025-05-29 14:09:32 +00:00
|
|
|
|
author_dict = info.context.get("author") or {}
|
2025-05-22 01:34:30 +00:00
|
|
|
|
author_id = author_dict.get("id")
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-06-01 23:56:11 +00:00
|
|
|
|
if not author_id or not isinstance(author_id, int):
|
2025-05-22 01:34:30 +00:00
|
|
|
|
return {"error": "Author ID is required"}
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
try:
|
|
|
|
|
with local_session() as session:
|
|
|
|
|
# Загружаем черновик со связанной публикацией
|
|
|
|
|
draft = (
|
|
|
|
|
session.query(Draft)
|
2025-05-29 09:37:39 +00:00
|
|
|
|
.options(joinedload(Draft.publication), joinedload(Draft.authors), joinedload(Draft.topics))
|
2025-05-22 01:34:30 +00:00
|
|
|
|
.filter(Draft.id == draft_id)
|
|
|
|
|
.first()
|
|
|
|
|
)
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
if not draft:
|
|
|
|
|
return {"error": "Draft not found"}
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
# Проверяем, есть ли публикация
|
|
|
|
|
if not draft.publication:
|
|
|
|
|
return {"error": "This draft is not published yet"}
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
shout = draft.publication
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
# Снимаем с публикации
|
|
|
|
|
shout.published_at = None
|
|
|
|
|
shout.updated_at = int(time.time())
|
|
|
|
|
shout.updated_by = author_id
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
session.commit()
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
# Инвалидируем кэш
|
|
|
|
|
cache_keys = [f"shouts:{shout.id}"]
|
|
|
|
|
await invalidate_shouts_cache(cache_keys)
|
|
|
|
|
await invalidate_shout_related_cache(shout, author_id)
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
# Формируем результат
|
|
|
|
|
draft_dict = draft.dict()
|
|
|
|
|
# Добавляем информацию о публикации
|
2025-05-29 09:37:39 +00:00
|
|
|
|
draft_dict["publication"] = {"id": shout.id, "slug": shout.slug, "published_at": None}
|
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
logger.info(f"Successfully unpublished shout #{shout.id} for draft #{draft_id}")
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
return {"draft": draft_dict}
|
2025-05-29 09:37:39 +00:00
|
|
|
|
|
2025-05-22 01:34:30 +00:00
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error(f"Failed to unpublish draft {draft_id}: {e}", exc_info=True)
|
2025-06-01 23:56:11 +00:00
|
|
|
|
return {"error": f"Failed to unpublish draft: {e!s}"}
|