From 8c618691f6a955ca6af9230396b8dc8c54644972 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 24 Sep 2026 14:05:21 +0300 Subject: [PATCH 1/2] =?UTF-8?q?:sparkles:=20feat(portals):=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=B2=D1=8F=D0=B7=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support_portals/article_curation.py | 31 +++ .../support_portals/content_services.py | 7 +- .../support_portals/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/apply_portal_curation.py | 96 +++++++++ .../migrations/0012_article_curation.py | 20 ++ .../chatballs/support_portals/models.py | 2 + .../chatballs/support_portals/public_views.py | 17 +- .../chatballs/support_portals/selectors.py | 9 +- .../chatballs/support_portals/serializers.py | 2 + .../tests/test_article_curation.py | 139 ++++++++++++ .../src/features/help-center/HelpArticle.tsx | 6 +- .../features/help-center/HelpCenterApp.tsx | 2 + .../src/features/help-center/HelpHome.tsx | 3 +- .../src/features/help-center/HelpLayout.tsx | 21 +- .../features/help-center/HelpNavigation.tsx | 178 ++++++++++++++++ .../help-center/HelpRelatedArticles.tsx | 22 ++ .../src/features/help-center/api.ts | 4 +- .../features/help-center/styles-layout.css | 5 +- .../help-center/styles-navigation.css | 201 ++++++++++++++++++ .../features/help-center/styles-related.css | 49 +++++ .../src/features/help-center/types.ts | 1 + apps/internal-ui/src/i18n/en.ts | 3 + apps/internal-ui/src/i18n/ru.ts | 3 + docs/portal-curation/README.md | 23 ++ docs/portal-curation/chatballs.json | 92 ++++++++ 26 files changed, 920 insertions(+), 16 deletions(-) create mode 100644 apps/backend/chatballs/support_portals/article_curation.py create mode 100644 apps/backend/chatballs/support_portals/management/__init__.py create mode 100644 apps/backend/chatballs/support_portals/management/commands/__init__.py create mode 100644 apps/backend/chatballs/support_portals/management/commands/apply_portal_curation.py create mode 100644 apps/backend/chatballs/support_portals/migrations/0012_article_curation.py create mode 100644 apps/backend/chatballs/support_portals/tests/test_article_curation.py create mode 100644 apps/internal-ui/src/features/help-center/HelpNavigation.tsx create mode 100644 apps/internal-ui/src/features/help-center/HelpRelatedArticles.tsx create mode 100644 apps/internal-ui/src/features/help-center/styles-navigation.css create mode 100644 apps/internal-ui/src/features/help-center/styles-related.css create mode 100644 docs/portal-curation/README.md create mode 100644 docs/portal-curation/chatballs.json diff --git a/apps/backend/chatballs/support_portals/article_curation.py b/apps/backend/chatballs/support_portals/article_curation.py new file mode 100644 index 0000000..e4b2aba --- /dev/null +++ b/apps/backend/chatballs/support_portals/article_curation.py @@ -0,0 +1,31 @@ +from django.core.exceptions import ValidationError + +from chatballs.i18n import t +from chatballs.support_portals.models import PortalArticle +from chatballs.support_portals.statuses import ArticleStatus + + +def apply_article_curation(article: PortalArticle, data: dict) -> None: + if "sortOrder" in data: + article.sort_order = int(data["sortOrder"]) + if "relatedArticleIds" not in data: + return + related_ids = data["relatedArticleIds"] + if ( + not isinstance(related_ids, list) + or len(related_ids) > 5 + or any(type(item) is not int for item in related_ids) + or len(set(related_ids)) != len(related_ids) + or article.id in related_ids + ): + raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")}) + valid_count = PortalArticle.objects.filter( + id__in=related_ids, + portal=article.portal, + locale=article.locale, + status=ArticleStatus.PUBLISHED, + published_revision__isnull=False, + ).count() + if valid_count != len(related_ids): + raise ValidationError({"relatedArticleIds": t("portals.invalid_article_data")}) + article.related_article_ids = related_ids diff --git a/apps/backend/chatballs/support_portals/content_services.py b/apps/backend/chatballs/support_portals/content_services.py index 39b9120..706c40a 100644 --- a/apps/backend/chatballs/support_portals/content_services.py +++ b/apps/backend/chatballs/support_portals/content_services.py @@ -7,6 +7,7 @@ from django.utils import timezone from django.utils.text import slugify from chatballs.ai.indexing import reindex_portal_article +from chatballs.support_portals.article_curation import apply_article_curation from chatballs.conversations.transports.base import guess_content_type, safe_filename from chatballs.i18n import t from chatballs.support_portals.content_markdown import normalize_file_links @@ -104,6 +105,7 @@ def create_article( category=category, slug=str(data.get("slug", "")).strip().lower(), locale=str(data.get("locale", portal.default_locale)).strip().lower(), + sort_order=1000, ) article.full_clean() article.save() @@ -143,8 +145,11 @@ def update_article( ) article.slug = str(data.get("slug", article.slug)).strip().lower() article.locale = str(data.get("locale", article.locale)).strip().lower() + apply_article_curation(article, data) article.full_clean() - article.save(update_fields=["category", "slug", "locale", "updated_at"]) + article.save(update_fields=[ + "category", "slug", "locale", "sort_order", "related_article_ids", "updated_at", + ]) return article diff --git a/apps/backend/chatballs/support_portals/management/__init__.py b/apps/backend/chatballs/support_portals/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/chatballs/support_portals/management/commands/__init__.py b/apps/backend/chatballs/support_portals/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/backend/chatballs/support_portals/management/commands/apply_portal_curation.py b/apps/backend/chatballs/support_portals/management/commands/apply_portal_curation.py new file mode 100644 index 0000000..4005389 --- /dev/null +++ b/apps/backend/chatballs/support_portals/management/commands/apply_portal_curation.py @@ -0,0 +1,96 @@ +import json +import sys +from pathlib import Path + +from django.core.management.base import BaseCommand, CommandError + +from chatballs.support_portals.models import PortalArticle, SupportPortal +from chatballs.tenancy.context import TenantContext +from chatballs.tenancy.database import tenant_atomic +from chatballs.tenancy.ingress import support_portal_route +from chatballs.tenancy.lookup import load_organization + + +def checked_changes(portal: SupportPortal, plan: dict) -> list[PortalArticle]: + if plan.get("portal") != portal.slug: + raise CommandError("Portal slug differs from the curation plan") + locale = plan.get("locale") + order = plan.get("order") + related = plan.get("related") + if not isinstance(locale, str) or not isinstance(order, dict) or not isinstance(related, dict): + raise CommandError("Invalid curation plan") + articles = list( + portal.articles.filter( + locale=locale, status="PUBLISHED", published_revision__isnull=False + ).select_related("category") + ) + by_slug = {item.slug: item for item in articles} + ordered = [slug for slugs in order.values() for slug in slugs] + if ( + len(ordered) != len(by_slug) + or len(set(ordered)) != len(ordered) + or set(ordered) != set(by_slug) + or set(related) != set(by_slug) + ): + raise CommandError("Plan must cover every published article exactly once") + for category_slug, slugs in order.items(): + if not isinstance(slugs, list) or any( + by_slug[slug].category.slug != category_slug for slug in slugs + ): + raise CommandError(f"Wrong article category in {category_slug}") + for slug, targets in related.items(): + if ( + not isinstance(targets, list) + or len(targets) > 5 + or len(set(targets)) != len(targets) + or slug in targets + or not set(targets) <= set(by_slug) + ): + raise CommandError(f"Invalid related articles for {slug}") + changed = [] + for slugs in order.values(): + for position, slug in enumerate(slugs, start=1): + article = by_slug[slug] + target_ids = [by_slug[target].id for target in related[slug]] + if article.sort_order != position or article.related_article_ids != target_ids: + article.sort_order = position + article.related_article_ids = target_ids + changed.append(article) + return changed + + +class Command(BaseCommand): + help = "Validate or apply an editorial order and related articles to a public portal" + + def add_arguments(self, parser): + parser.add_argument("--host", required=True) + parser.add_argument("--file", required=True, help="JSON plan path or - for stdin") + parser.add_argument("--apply", action="store_true") + + def handle(self, *args, **options): + hostname = options["host"].strip().lower().rstrip(".") + route = support_portal_route(hostname) + organization = load_organization(route.organization_id) if route else None + if organization is None: + raise CommandError("Public portal host not found") + source = sys.stdin.read() if options["file"] == "-" else Path(options["file"]).read_text(encoding="utf-8") + try: + plan = json.loads(source) + except json.JSONDecodeError as error: + raise CommandError("Invalid JSON plan") from error + context = TenantContext.for_resource(organization) + with tenant_atomic(context): + portal = SupportPortal.objects.filter( + id=route.resource_id, + organization=organization, + status="PUBLISHED", + ).first() + if portal is None: + raise CommandError("Published portal not found") + changed = checked_changes(portal, plan) + if options["apply"] and changed: + PortalArticle.objects.bulk_update( + changed, ["sort_order", "related_article_ids"] + ) + action = "Updated" if options["apply"] else "Would update" + self.stdout.write(f"{action} {len(changed)} articles") diff --git a/apps/backend/chatballs/support_portals/migrations/0012_article_curation.py b/apps/backend/chatballs/support_portals/migrations/0012_article_curation.py new file mode 100644 index 0000000..73a3bb1 --- /dev/null +++ b/apps/backend/chatballs/support_portals/migrations/0012_article_curation.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("support_portals", "0011_hosted_domains_follow_installation"), + ] + + operations = [ + migrations.AddField( + model_name="portalarticle", + name="sort_order", + field=models.PositiveIntegerField(default=1000), + ), + migrations.AddField( + model_name="portalarticle", + name="related_article_ids", + field=models.JSONField(blank=True, default=list), + ), + ] diff --git a/apps/backend/chatballs/support_portals/models.py b/apps/backend/chatballs/support_portals/models.py index 74a3e7a..d932895 100644 --- a/apps/backend/chatballs/support_portals/models.py +++ b/apps/backend/chatballs/support_portals/models.py @@ -153,6 +153,8 @@ class PortalArticle(TenantRelationModel): ) slug = models.SlugField(max_length=96) locale = models.CharField(max_length=16, default="ru") + sort_order = models.PositiveIntegerField(default=1000) + related_article_ids = models.JSONField(default=list, blank=True) status = models.CharField( max_length=16, choices=ArticleStatus.choices, diff --git a/apps/backend/chatballs/support_portals/public_views.py b/apps/backend/chatballs/support_portals/public_views.py index 59354a5..c1e98dc 100644 --- a/apps/backend/chatballs/support_portals/public_views.py +++ b/apps/backend/chatballs/support_portals/public_views.py @@ -58,7 +58,9 @@ class PublicPortalDetailView(PublicPortalView): return Response({"detail": t("portals.not_found")}, status=404) context, portal = resolved with tenant_atomic(context): - counts = category_article_counts(portal, published_only=True) + counts = category_article_counts( + portal, published_only=True, locale=portal.default_locale + ) return Response( { "portal": public_portal_payload(portal), @@ -85,6 +87,7 @@ class PublicArticleListView(PublicPortalView): locale=locale, category=str(request.query_params.get("category", "")), query=str(request.query_params.get("q", "")).strip(), + direct_category=request.query_params.get("direct") == "1", ) try: limit = min(max(int(request.query_params.get("limit", 50)), 1), 100) @@ -122,7 +125,17 @@ class PublicArticleDetailView(PublicPortalView): ).first() if article is None: return Response({"detail": t("portals.article_not_found")}, status=404) - return Response({"article": public_article_payload(article)}) + payload = public_article_payload(article) + related = public_articles(portal, locale=locale).filter( + id__in=article.related_article_ids + ) + by_id = {item.id: item for item in related} + payload["relatedArticles"] = [ + public_article_payload(by_id[item_id], content=False) + for item_id in article.related_article_ids + if item_id in by_id + ] + return Response({"article": payload}) class PublicArticleFeedbackView(PublicPortalView): diff --git a/apps/backend/chatballs/support_portals/selectors.py b/apps/backend/chatballs/support_portals/selectors.py index 7e8cbad..880ca52 100644 --- a/apps/backend/chatballs/support_portals/selectors.py +++ b/apps/backend/chatballs/support_portals/selectors.py @@ -108,6 +108,7 @@ def public_articles( locale: str, category: str = "", query: str = "", + direct_category: bool = False, ) -> QuerySet[PortalArticle]: articles = ( PortalArticle.objects.filter( @@ -118,13 +119,13 @@ def public_articles( ) .select_related("category", "published_revision") .prefetch_related("files") - .order_by("category__sort_order", "published_revision__title") + .order_by("category__sort_order", "sort_order", "published_revision__title", "id") ) if category: root = portal.categories.filter(slug=category).first() if root is None: return articles.none() - category_ids = descendant_category_ids(portal, root.id) + category_ids = {root.id} if direct_category else descendant_category_ids(portal, root.id) articles = articles.filter(category_id__in=category_ids) if query: articles = articles.filter( @@ -151,10 +152,12 @@ def descendant_category_ids(portal: SupportPortal, root_id: int) -> set[int]: def category_article_counts( - portal: SupportPortal, *, published_only: bool = False + portal: SupportPortal, *, published_only: bool = False, locale: str | None = None ) -> dict[int, int]: categories = list(portal.categories.all()) direct_query = PortalArticle.objects.filter(portal=portal) + if locale is not None: + direct_query = direct_query.filter(locale=locale) if published_only: direct_query = direct_query.filter( status="PUBLISHED", published_revision__isnull=False diff --git a/apps/backend/chatballs/support_portals/serializers.py b/apps/backend/chatballs/support_portals/serializers.py index 2ca631d..785708e 100644 --- a/apps/backend/chatballs/support_portals/serializers.py +++ b/apps/backend/chatballs/support_portals/serializers.py @@ -107,6 +107,8 @@ def article_payload( "id": article.id, "slug": article.slug, "locale": article.locale, + "sortOrder": article.sort_order, + "relatedArticleIds": article.related_article_ids, "status": article.status, "category": category_payload(article.category), "publishedRevision": ( diff --git a/apps/backend/chatballs/support_portals/tests/test_article_curation.py b/apps/backend/chatballs/support_portals/tests/test_article_curation.py new file mode 100644 index 0000000..3ef2c3a --- /dev/null +++ b/apps/backend/chatballs/support_portals/tests/test_article_curation.py @@ -0,0 +1,139 @@ +import json +from io import StringIO +from unittest.mock import patch + +from django.core.management import call_command + +from chatballs.support_portals.tests.base import SupportPortalTestCase + + +class ArticleCurationTests(SupportPortalTestCase): + def setUp(self) -> None: + super().setUp() + portal = self.create_portal().json()["portal"] + self.portal_id = portal["id"] + self.portal_host = portal["hostedDomain"] + self.root = self._category("Начало", "start") + self.child = self._category("Продолжение", "next", parent_id=self.root) + + def _category(self, name: str, slug: str, *, parent_id: int | None = None) -> int: + payload = {"name": name, "slug": slug} + if parent_id is not None: + payload["parentId"] = parent_id + response = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/categories/", + payload, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + return response.json()["category"]["id"] + + def _article(self, slug: str, title: str, category_id: int) -> int: + response = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/", + { + "categoryId": category_id, + "slug": slug, + "title": title, + "summary": title, + "content": title, + }, + format="json", + ) + self.assertEqual(response.status_code, 201, response.content) + article = response.json()["article"] + published = self.client.post( + f"/api/v1/support/portals/{self.portal_id}/articles/{article['id']}/publish/", + {"revisionId": article["revisions"][0]["id"]}, + format="json", + ) + self.assertEqual(published.status_code, 200, published.content) + return article["id"] + + def test_order_related_articles_and_direct_category(self) -> None: + first = self._article("first", "Альфа", self.root) + second = self._article("second", "Бета", self.root) + child = self._article("child", "Гамма", self.child) + base = f"/api/v1/support/portals/{self.portal_id}/articles/" + for article_id, data in ( + (first, {"sortOrder": 2, "relatedArticleIds": [child, second]}), + (second, {"sortOrder": 1}), + ): + response = self.client.patch( + f"{base}{article_id}/", data, format="json" + ) + self.assertEqual(response.status_code, 200, response.content) + invalid = self.client.patch( + f"{base}{first}/", + {"relatedArticleIds": [first]}, + format="json", + ) + self.assertEqual(invalid.status_code, 400) + self.client.post( + f"/api/v1/support/portals/{self.portal_id}/status/", + {"status": "PUBLISHED"}, + format="json", + ) + self.client.logout() + listing = self.client.get( + "/api/v1/help/articles/?category=start&direct=1", + HTTP_HOST=self.portal_host, + ) + self.assertEqual(listing.status_code, 200, listing.content) + self.assertEqual( + [item["slug"] for item in listing.json()["items"]], + ["second", "first"], + ) + detail = self.client.get( + "/api/v1/help/articles/first/", HTTP_HOST=self.portal_host + ) + self.assertEqual(detail.status_code, 200, detail.content) + self.assertEqual( + [item["slug"] for item in detail.json()["article"]["relatedArticles"]], + ["child", "second"], + ) + + def test_curation_command_dry_run_and_apply(self) -> None: + first = self._article("first", "Альфа", self.root) + second = self._article("second", "Бета", self.root) + self.client.post( + f"/api/v1/support/portals/{self.portal_id}/status/", + {"status": "PUBLISHED"}, + format="json", + ) + plan = json.dumps({ + "portal": "app-help", + "locale": "ru", + "order": {"start": ["second", "first"]}, + "related": {"first": ["second"], "second": ["first"]}, + }) + output = StringIO() + with patch("sys.stdin", StringIO(plan)): + call_command( + "apply_portal_curation", host=self.portal_host, file="-", stdout=output + ) + self.assertIn("Would update 2 articles", output.getvalue()) + output = StringIO() + with patch("sys.stdin", StringIO(plan)): + call_command( + "apply_portal_curation", host=self.portal_host, file="-", apply=True, + stdout=output, + ) + self.assertIn("Updated 2 articles", output.getvalue()) + self.client.logout() + listing = self.client.get( + "/api/v1/help/articles/?category=start&direct=1", + HTTP_HOST=self.portal_host, + ) + self.assertEqual( + [item["slug"] for item in listing.json()["items"]], + ["second", "first"], + ) + detail = self.client.get( + "/api/v1/help/articles/first/", HTTP_HOST=self.portal_host + ) + self.assertEqual( + [item["slug"] for item in detail.json()["article"]["relatedArticles"]], + ["second"], + ) + self.assertNotEqual(first, second) diff --git a/apps/internal-ui/src/features/help-center/HelpArticle.tsx b/apps/internal-ui/src/features/help-center/HelpArticle.tsx index aa5dce1..2bf2db1 100644 --- a/apps/internal-ui/src/features/help-center/HelpArticle.tsx +++ b/apps/internal-ui/src/features/help-center/HelpArticle.tsx @@ -4,6 +4,7 @@ import { fetchHelpArticle, sendArticleFeedback } from "./api"; import { HelpChevronIcon, HelpThumbIcon } from "./HelpIcons"; import { HelpLayout } from "./HelpLayout"; import { HelpAttachments } from "./HelpAttachments"; +import { HelpRelatedArticles } from "./HelpRelatedArticles"; import { MarkdownContent, parseMarkdown } from "./MarkdownContent"; import type { HelpArticle as HelpArticleType, HelpManifest } from "./types"; import { t } from "../../i18n"; @@ -63,7 +64,9 @@ export function HelpArticle({ return ( )} + {headings.length > 0 && (