2026-08-18 11:55:43 +02:00
|
|
|
"""Wiki plugin services — CRUD for articles, categories, versioning."""
|
|
|
|
|
from __future__ import annotations
|
2026-08-24 13:36:17 +02:00
|
|
|
|
2026-08-18 11:55:43 +02:00
|
|
|
import uuid
|
|
|
|
|
from datetime import UTC, datetime
|
|
|
|
|
from typing import Any
|
2026-08-24 13:36:17 +02:00
|
|
|
|
|
|
|
|
from sqlalchemy import func, select
|
2026-08-18 11:55:43 +02:00
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
2026-08-24 13:36:17 +02:00
|
|
|
|
2026-08-18 11:55:43 +02:00
|
|
|
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)
|