diff --git a/CHANGELOG.md b/CHANGELOG.md index be0aa577..b46a8fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ - **Исправлена критическая ошибка с уведомлениями**: Устранена ошибка `null value in column "kind" of relation "notification" violates not-null constraint` - **Исправлен возвращаемый формат publish_draft**: Теперь возвращается `{"draft": draft_dict}` вместо `{"shout": shout}` для соответствия GraphQL схеме - **Фронтенд получает корректные данные**: При публикации черновика фронтенд теперь получает ожидаемое поле `draft` вместо `null` +- **Исправлена ошибка GraphQL**: Устранена ошибка "Cannot return null for non-nullable field Draft.topics" при публикации черновиков ### 🏗️ Changed - **Обновлена функция save_notification**: Добавлено обязательное поле `kind` для создания уведомлений - **Исправлена типизация**: Поле `kind` теперь корректно преобразуется из `action` в `NotificationAction` enum - **Убрано неиспользуемое значение PUBLISHED**: Из enum `NotificationAction` убрано значение, которое не использовалось +- **Рефакторинг кода**: Создана вспомогательная функция `create_draft_dict()` для избежания дублирования в `publish_draft` и `unpublish_draft` ### 📦 Added - **Добавлен fallback для нестандартных действий**: Если `action` не соответствует enum, используется `NotificationAction.CREATE` diff --git a/resolvers/draft.py b/resolvers/draft.py index f770d75a..2a392be3 100644 --- a/resolvers/draft.py +++ b/resolvers/draft.py @@ -17,9 +17,52 @@ from services.search import search_service from storage.db import local_session from storage.schema import mutation, query from utils.extract_text import extract_text +from utils.validators import validate_html_content from utils.logger import root_logger as logger +def create_draft_dict(draft: Draft) -> dict[str, Any]: + """ + Создает словарь с данными черновика, избегая проблем с null значениями в связях. + + Args: + draft: Объект черновика + + Returns: + dict: Словарь с данными черновика + """ + return { + "id": draft.id, + "created_at": draft.created_at, + "created_by": draft.created_by, + "community": draft.community, + "layout": draft.layout, + "slug": draft.slug, + "title": draft.title, + "subtitle": draft.subtitle, + "lead": draft.lead, + "body": draft.body, + "media": draft.media, + "cover": draft.cover, + "cover_caption": draft.cover_caption, + "lang": draft.lang, + "seo": draft.seo, + "updated_at": draft.updated_at, + "deleted_at": draft.deleted_at, + "updated_by": draft.updated_by, + "deleted_by": draft.deleted_by, + "shout": draft.shout, + # Явно загружаем связи, чтобы избежать null значений + "authors": [ + {"id": a.id, "name": a.name, "slug": a.slug, "pic": getattr(a, "pic", None)} for a in (draft.authors or []) + ], + "topics": [ + {"id": t.id, "name": t.name, "slug": t.slug, "is_main": getattr(t, "is_main", False)} + for t in (draft.topics or []) + ], + } + + def create_shout_from_draft(session: Session | None, draft: Draft, author_id: int) -> Shout: """ Создаёт новый объект публикации (Shout) на основе черновика. @@ -358,39 +401,6 @@ async def delete_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict return {"draft": draft} -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("

Valid HTML

") - >>> 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: - extracted = extract_text(html_content) - return bool(extracted), extracted or "" - except Exception as e: - logger.error(f"HTML validation error: {e}", exc_info=True) - return False, f"Invalid HTML content: {e!s}" - - @mutation.field("publish_draft") @login_required async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dict[str, Any]: @@ -503,7 +513,9 @@ async def publish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> dic logger.debug(f"Shout data: {shout.dict()}") # Возвращаем обновленный черновик с информацией о shout - draft_dict = draft.dict() + draft_dict = create_draft_dict(draft) + + # Добавляем информацию о shout draft_dict["shout"] = {"id": shout.id, "slug": shout.slug, "published_at": shout.published_at} return {"draft": draft_dict} @@ -570,7 +582,8 @@ async def unpublish_draft(_: None, info: GraphQLResolveInfo, draft_id: int) -> d await invalidate_shout_related_cache(shout, author_id) # Формируем результат - draft_dict = draft.dict() + draft_dict = create_draft_dict(draft) + # Добавляем информацию о публикации draft_dict["shout"] = {"id": shout.id, "slug": shout.slug, "published_at": None} diff --git a/tests/test_draft_publish_fix.py b/tests/test_draft_publish_fix.py index d607a6f7..930690ed 100644 --- a/tests/test_draft_publish_fix.py +++ b/tests/test_draft_publish_fix.py @@ -138,6 +138,15 @@ async def test_publish_draft_returns_draft(): assert draft_data["id"] == 1 assert "shout" in draft_data + # Проверяем, что authors и topics не null + assert "authors" in draft_data + assert draft_data["authors"] is not None + assert isinstance(draft_data["authors"], list) + + assert "topics" in draft_data + assert draft_data["topics"] is not None + assert isinstance(draft_data["topics"], list) + shout_data = draft_data["shout"] assert shout_data["id"] == 100 assert shout_data["slug"] == "test-shout" diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 00000000..399939fd --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,34 @@ +from utils.extract_text import extract_text +from utils.logger import root_logger as logger + +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("

Valid HTML

") + >>> 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, "" + + try: + extracted = extract_text(html_content) + return bool(extracted), extracted or "" + except Exception as e: + logger.error(f"HTML validation error: {e}", exc_info=True) + return False, f"Invalid HTML content: {e!s}" \ No newline at end of file