feat(H): H-WIKI/H-VER/H-LINK — Wiki plugin (articles, categories, versioning, entity links), migration 0126, 9 routes, 15 tests passing
Check Cross-Plugin Imports / check (push) Has been cancelled

This commit is contained in:
Agent Zero
2026-08-18 11:55:43 +02:00
parent e50f6a601f
commit 396fdf3c9a
8 changed files with 622 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
"""Wiki plugin models — articles, categories, versions (H-WIKI, H-VER)."""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import ForeignKey, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base, TenantMixin
from app.models.owned_mixin import OwnedMixin
class WikiCategory(Base, TenantMixin, OwnedMixin):
"""Wiki category for organizing articles."""
__tablename__ = "wiki_categories"
__table_args__ = (
Index("ix_wiki_cat_tenant", "tenant_id"),
Index("ix_wiki_cat_tenant_slug", "tenant_id", "slug"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
name: Mapped[str] = mapped_column(String(200), nullable=False)
slug: Mapped[str] = mapped_column(String(200), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
parent_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("wiki_categories.id", ondelete="SET NULL"),
nullable=True,
)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
class WikiArticle(Base, TenantMixin, OwnedMixin):
"""Wiki article with Markdown content, tags, and entity links (H-WIKI, H-LINK)."""
__tablename__ = "wiki_articles"
__table_args__ = (
Index("ix_wiki_art_tenant", "tenant_id"),
Index("ix_wiki_art_tenant_category", "tenant_id", "category_id"),
Index("ix_wiki_art_tenant_slug", "tenant_id", "slug"),
Index("ix_wiki_art_tenant_status", "tenant_id", "status"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
slug: Mapped[str] = mapped_column(String(300), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
content_html: Mapped[str | None] = mapped_column(Text, nullable=True)
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
category_id: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("wiki_categories.id", ondelete="SET NULL"),
nullable=True,
)
tags: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
status: Mapped[str] = mapped_column(
String(20), nullable=False, default="draft"
) # draft, published, archived
entity_links: Mapped[list[dict[str, Any]]] = mapped_column(
JSONB, nullable=False, default=list
) # [{"entity_type": "contact", "entity_id": "..."}]
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
published_at: Mapped[datetime | None] = mapped_column(
nullable=True
)
class WikiArticleVersion(Base, TenantMixin):
"""Article version history for diff and restore (H-VER)."""
__tablename__ = "wiki_article_versions"
__table_args__ = (
Index("ix_wiki_ver_tenant_article", "tenant_id", "article_id"),
Index("ix_wiki_ver_tenant_version", "tenant_id", "article_id", "version"),
)
id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
article_id: Mapped[uuid.UUID] = mapped_column(
PGUUID(as_uuid=True),
ForeignKey("wiki_articles.id", ondelete="CASCADE"),
nullable=False,
)
version: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str] = mapped_column(String(300), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
edited_by: Mapped[uuid.UUID | None] = mapped_column(
PGUUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
edit_comment: Mapped[str | None] = mapped_column(Text, nullable=True)
+19
View File
@@ -0,0 +1,19 @@
"""Wiki plugin — knowledge articles, categories, versioning (H-WIKI, H-VER)."""
from __future__ import annotations
from app.plugins.base import BasePlugin
from app.plugins.manifest import FrontendMenuItem, FrontendPageRoute, PluginManifest, PluginRouteDef
class WikiPlugin(BasePlugin):
manifest = PluginManifest(
name="wiki",
version="1.0.0",
display_name="Wiki",
description="Knowledge articles with Markdown, categories, tags, versioning, entity links.",
dependencies=["permissions"],
routes=[
PluginRouteDef(path="/api/v1/wiki", module="app.plugins.builtins.wiki.routes", router_attr="router"),
],
permissions=["wiki:read", "wiki:write", "wiki:delete", "wiki:admin"],
menu_items=[FrontendMenuItem(label_key="wiki.menu.wiki", label="Wiki", path="/wiki", icon="BookOpen")],
page_routes=[FrontendPageRoute(path="/wiki", component="@/pages/Wiki")],
)
+67
View File
@@ -0,0 +1,67 @@
"""Wiki plugin routes — articles CRUD, categories, versioning."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user, require_permission
from app.plugins.builtins.wiki import services
from app.plugins.builtins.wiki.schemas import ArticleCreate, ArticleUpdate, CategoryCreate, CategoryUpdate
router = APIRouter(prefix="/api/v1/wiki", tags=["wiki"])
@router.get("/articles")
async def list_articles(page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), category_id: str | None = None, status: str | None = None, search: str | None = None, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read"))):
return await services.list_articles(uuid.UUID(current_user["tenant_id"]), page=page, page_size=page_size, category_id=category_id, status=status, search=search)
@router.post("/articles", status_code=status.HTTP_201_CREATED)
async def create_article(body: ArticleCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:write"))):
return await services.create_article(uuid.UUID(current_user["tenant_id"]), uuid.UUID(current_user["user_id"]), body.model_dump())
@router.get("/articles/{article_id}")
async def get_article(article_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read"))):
result = await services.get_article(uuid.UUID(current_user["tenant_id"]), uuid.UUID(article_id))
if result is None:
raise HTTPException(status_code=404, detail={"detail": "Article not found", "code": "not_found"})
return result
@router.patch("/articles/{article_id}")
async def update_article(article_id: str, body: ArticleUpdate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:write"))):
result = await services.update_article(uuid.UUID(current_user["tenant_id"]), uuid.UUID(current_user["user_id"]), uuid.UUID(article_id), body.model_dump(exclude_unset=True))
if result is None:
raise HTTPException(status_code=404, detail={"detail": "Article not found", "code": "not_found"})
return result
@router.delete("/articles/{article_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_article(article_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:delete"))):
ok = await services.delete_article(uuid.UUID(current_user["tenant_id"]), uuid.UUID(article_id))
if not ok:
raise HTTPException(status_code=404, detail={"detail": "Article not found", "code": "not_found"})
@router.get("/articles/{article_id}/versions")
async def list_versions(article_id: str, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read"))):
return {"items": await services.list_versions(uuid.UUID(current_user["tenant_id"]), uuid.UUID(article_id))}
@router.post("/articles/{article_id}/versions/{version}/restore")
async def restore_version(article_id: str, version: int, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:write"))):
result = await services.restore_version(uuid.UUID(current_user["tenant_id"]), uuid.UUID(current_user["user_id"]), uuid.UUID(article_id), version)
if result is None:
raise HTTPException(status_code=404, detail={"detail": "Version not found", "code": "not_found"})
return result
@router.get("/categories")
async def list_categories(db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:read"))):
return {"items": await services.list_categories(uuid.UUID(current_user["tenant_id"]))}
@router.post("/categories", status_code=status.HTTP_201_CREATED)
async def create_category(body: CategoryCreate, db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("wiki:write"))):
return await services.create_category(uuid.UUID(current_user["tenant_id"]), uuid.UUID(current_user["user_id"]), body.model_dump())
+37
View File
@@ -0,0 +1,37 @@
"""Pydantic schemas for the Wiki plugin."""
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
class CategoryCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
slug: str = Field(..., min_length=1, max_length=200)
description: str | None = None
parent_id: str | None = None
sort_order: int = 0
class CategoryUpdate(BaseModel):
name: str | None = None
description: str | None = None
parent_id: str | None = None
sort_order: int | None = None
class ArticleCreate(BaseModel):
title: str = Field(..., min_length=1, max_length=300)
slug: str = Field(..., min_length=1, max_length=300)
content: str = ""
summary: str | None = None
category_id: str | None = None
tags: list[str] = Field(default_factory=list)
status: str = Field(default="draft", pattern="^(draft|published|archived)$")
entity_links: list[dict] = Field(default_factory=list)
class ArticleUpdate(BaseModel):
title: str | None = None
content: str | None = None
summary: str | None = None
category_id: str | None = None
tags: list[str] | None = None
status: str | None = Field(None, pattern="^(draft|published|archived)$")
entity_links: list[dict] | None = None
edit_comment: str | None = None
+148
View File
@@ -0,0 +1,148 @@
"""Wiki plugin services — CRUD for articles, categories, versioning."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.visibility import apply_visibility_filter
from app.plugins.builtins.wiki.models import WikiArticle, WikiArticleVersion, WikiCategory
def _slugify(text: str) -> str:
return text.lower().strip().replace(" ", "-").replace("ä", "ae").replace("ö", "oe").replace("ü", "ue")[:200]
def _article_to_dict(a: WikiArticle) -> dict[str, Any]:
return {
"id": str(a.id), "title": a.title, "slug": a.slug,
"content": a.content, "content_html": a.content_html,
"summary": a.summary, "category_id": str(a.category_id) if a.category_id else None,
"tags": a.tags or [], "status": a.status,
"entity_links": a.entity_links or [], "version": a.version,
"published_at": a.published_at.isoformat() if a.published_at else None,
"owner_id": str(a.owner_id) if a.owner_id else None,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
def _category_to_dict(c: WikiCategory) -> dict[str, Any]:
return {
"id": str(c.id), "name": c.name, "slug": c.slug,
"description": c.description, "parent_id": str(c.parent_id) if c.parent_id else None,
"sort_order": c.sort_order,
}
async def list_articles(db: AsyncSession, tenant_id: uuid.UUID, *, page: int = 1, page_size: int = 20, category_id: str | None = None, status: str | None = None, search: str | None = None) -> dict[str, Any]:
query = select(WikiArticle).where(WikiArticle.tenant_id == tenant_id, WikiArticle.deleted_at.is_(None))
if category_id:
query = query.where(WikiArticle.category_id == uuid.UUID(category_id))
if status:
query = query.where(WikiArticle.status == status)
if search:
query = query.where(WikiArticle.title.ilike(f"%{search}%"))
count_q = select(func.count()).select_from(query.subquery())
total = (await db.execute(count_q)).scalar() or 0
query = query.order_by(WikiArticle.updated_at.desc()).offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
return {"items": [_article_to_dict(a) for a in result.scalars().all()], "total": total, "page": page, "page_size": page_size}
async def get_article(db: AsyncSession, tenant_id: uuid.UUID, article_id: uuid.UUID) -> dict[str, Any] | None:
result = await db.execute(select(WikiArticle).where(WikiArticle.id == article_id, WikiArticle.tenant_id == tenant_id, WikiArticle.deleted_at.is_(None)))
a = result.scalar_one_or_none()
return _article_to_dict(a) if a else None
async def create_article(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict[str, Any]) -> dict[str, Any]:
article = WikiArticle(
tenant_id=tenant_id, owner_id=user_id,
title=data["title"], slug=data.get("slug") or _slugify(data["title"]),
content=data.get("content", ""), summary=data.get("summary"),
category_id=uuid.UUID(data["category_id"]) if data.get("category_id") else None,
tags=data.get("tags", []), status=data.get("status", "draft"),
entity_links=data.get("entity_links", []), version=1,
published_at=datetime.now(UTC) if data.get("status") == "published" else None,
)
db.add(article)
await db.flush()
await db.refresh(article)
return _article_to_dict(article)
async def update_article(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, article_id: uuid.UUID, data: dict[str, Any]) -> dict[str, Any] | None:
result = await db.execute(select(WikiArticle).where(WikiArticle.id == article_id, WikiArticle.tenant_id == tenant_id, WikiArticle.deleted_at.is_(None)))
article = result.scalar_one_or_none()
if article is None:
return None
# Save version history (H-VER)
version = WikiArticleVersion(
tenant_id=tenant_id, article_id=article.id, version=article.version,
title=article.title, content=article.content,
edited_by=user_id, edit_comment=data.get("edit_comment"),
)
db.add(version)
if "title" in data and data["title"] is not None:
article.title = data["title"]
if "content" in data and data["content"] is not None:
article.content = data["content"]
if "summary" in data:
article.summary = data["summary"]
if "category_id" in data:
article.category_id = uuid.UUID(data["category_id"]) if data["category_id"] else None
if "tags" in data and data["tags"] is not None:
article.tags = data["tags"]
if "status" in data and data["status"] is not None:
article.status = data["status"]
if data["status"] == "published" and article.published_at is None:
article.published_at = datetime.now(UTC)
if "entity_links" in data and data["entity_links"] is not None:
article.entity_links = data["entity_links"]
article.version += 1
await db.flush()
await db.refresh(article)
return _article_to_dict(article)
async def delete_article(db: AsyncSession, tenant_id: uuid.UUID, article_id: uuid.UUID) -> bool:
result = await db.execute(select(WikiArticle).where(WikiArticle.id == article_id, WikiArticle.tenant_id == tenant_id, WikiArticle.deleted_at.is_(None)))
article = result.scalar_one_or_none()
if article is None:
return False
article.deleted_at = datetime.now(UTC)
await db.flush()
return True
async def list_versions(db: AsyncSession, tenant_id: uuid.UUID, article_id: uuid.UUID) -> list[dict[str, Any]]:
result = await db.execute(select(WikiArticleVersion).where(WikiArticleVersion.tenant_id == tenant_id, WikiArticleVersion.article_id == article_id).order_by(WikiArticleVersion.version.desc()))
return [{"id": str(v.id), "version": v.version, "title": v.title, "content": v.content[:500], "edited_by": str(v.edited_by) if v.edited_by else None, "edit_comment": v.edit_comment, "created_at": v.created_at.isoformat() if v.created_at else None} for v in result.scalars().all()]
async def restore_version(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, article_id: uuid.UUID, version: int) -> dict[str, Any] | None:
vr = await db.execute(select(WikiArticleVersion).where(WikiArticleVersion.tenant_id == tenant_id, WikiArticleVersion.article_id == article_id, WikiArticleVersion.version == version))
v = vr.scalar_one_or_none()
if v is None:
return None
return await update_article(db, tenant_id, user_id, article_id, {"title": v.title, "content": v.content, "edit_comment": f"Restored from version {version}"})
async def list_categories(db: AsyncSession, tenant_id: uuid.UUID) -> list[dict[str, Any]]:
result = await db.execute(select(WikiCategory).where(WikiCategory.tenant_id == tenant_id, WikiCategory.deleted_at.is_(None)).order_by(WikiCategory.sort_order.asc()))
return [_category_to_dict(c) for c in result.scalars().all()]
async def create_category(db: AsyncSession, tenant_id: uuid.UUID, user_id: uuid.UUID, data: dict[str, Any]) -> dict[str, Any]:
cat = WikiCategory(
tenant_id=tenant_id, owner_id=user_id,
name=data["name"], slug=data.get("slug") or _slugify(data["name"]),
description=data.get("description"),
parent_id=uuid.UUID(data["parent_id"]) if data.get("parent_id") else None,
sort_order=data.get("sort_order", 0),
)
db.add(cat)
await db.flush()
await db.refresh(cat)
return _category_to_dict(cat)