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..381b60e 100644
--- a/apps/backend/chatballs/support_portals/content_services.py
+++ b/apps/backend/chatballs/support_portals/content_services.py
@@ -9,6 +9,7 @@ from django.utils.text import slugify
from chatballs.ai.indexing import reindex_portal_article
from chatballs.conversations.transports.base import guess_content_type, safe_filename
from chatballs.i18n import t
+from chatballs.support_portals.article_curation import apply_article_curation
from chatballs.support_portals.content_markdown import normalize_file_links
from chatballs.support_portals.models import (
PortalArticle,
@@ -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 && (