feat: implement governed wiki vertical slice
Module Package Release / publish-packages (push) Successful in 12s

This commit is contained in:
2026-08-22 13:08:12 +02:00
parent 572cb91dce
commit 66c91351c9
24 changed files with 4934 additions and 84 deletions
+17
View File
@@ -0,0 +1,17 @@
"""Wiki persistence models."""
from govoplan_wiki.backend.db.models import (
WikiComment,
WikiPage,
WikiPageRevision,
WikiSpace,
WikiSpaceHistory,
)
__all__ = [
"WikiComment",
"WikiPage",
"WikiPageRevision",
"WikiSpace",
"WikiSpaceHistory",
]
+194
View File
@@ -0,0 +1,194 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
import uuid
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
JSON,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from govoplan_core.db.base import Base, TimestampMixin
def new_uuid() -> str:
return str(uuid.uuid4())
class WikiSpace(Base, TimestampMixin):
__tablename__ = "wiki_spaces"
__table_args__ = (
UniqueConstraint("tenant_id", "space_key", name="uq_wiki_space_key"),
Index("ix_wiki_space_catalog", "tenant_id", "archived_at", "title"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_key: Mapped[str] = mapped_column(String(120), nullable=False, index=True)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
title: Mapped[str] = mapped_column(String(500), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
acl_tokens: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
publish_mode: Mapped[str] = mapped_column(String(40), nullable=False)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class WikiSpaceHistory(Base, TimestampMixin):
__tablename__ = "wiki_space_history"
__table_args__ = (
UniqueConstraint(
"tenant_id", "space_id", "revision", name="uq_wiki_space_history_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_space_history_idempotency"
),
Index("ix_wiki_space_history_timeline", "tenant_id", "space_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_id: Mapped[str] = mapped_column(
ForeignKey("wiki_spaces.id", ondelete="RESTRICT"), nullable=False, index=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class WikiPage(Base, TimestampMixin):
__tablename__ = "wiki_pages"
__table_args__ = (
UniqueConstraint("tenant_id", "space_id", "path", name="uq_wiki_page_path"),
Index("ix_wiki_page_tree", "tenant_id", "space_id", "parent_page_id", "state"),
Index("ix_wiki_page_catalog", "tenant_id", "state", "updated_at"),
)
id: Mapped[str] = mapped_column(String(255), primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
space_id: Mapped[str] = mapped_column(
ForeignKey("wiki_spaces.id", ondelete="RESTRICT"), nullable=False, index=True
)
parent_page_id: Mapped[str | None] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=True, index=True
)
slug: Mapped[str] = mapped_column(String(160), nullable=False)
path: Mapped[str] = mapped_column(String(1_000), nullable=False)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
published_revision: Mapped[int | None] = mapped_column(Integer, nullable=True)
state: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
title: Mapped[str] = mapped_column(String(500), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
visibility: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
inherits_access: Mapped[bool] = mapped_column(nullable=False, default=True)
acl_tokens: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
labels: Mapped[list[str]] = mapped_column(JSON, nullable=False, default=list)
links: Mapped[list[dict[str, Any]]] = mapped_column(
JSON, nullable=False, default=list
)
redirect_page_id: Mapped[str | None] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=True
)
search_text: Mapped[str] = mapped_column(Text, nullable=False)
published_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
archived_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True, index=True
)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
updated_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
class WikiPageRevision(Base, TimestampMixin):
__tablename__ = "wiki_page_revisions"
__table_args__ = (
UniqueConstraint(
"tenant_id", "page_id", "revision", name="uq_wiki_page_revision"
),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_page_idempotency"
),
Index("ix_wiki_page_revision_timeline", "tenant_id", "page_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_id: Mapped[str] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=False, index=True
)
revision: Mapped[int] = mapped_column(Integer, nullable=False)
event_type: Mapped[str] = mapped_column(String(80), nullable=False, index=True)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
actor_id: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
change_reason: Mapped[str] = mapped_column(String(1_000), nullable=False)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
class WikiComment(Base, TimestampMixin):
__tablename__ = "wiki_comments"
__table_args__ = (
UniqueConstraint("tenant_id", "comment_id", name="uq_wiki_comment"),
UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_comment_idempotency"
),
Index("ix_wiki_comment_timeline", "tenant_id", "page_id", "recorded_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid)
tenant_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_id: Mapped[str] = mapped_column(
ForeignKey("wiki_pages.id", ondelete="RESTRICT"), nullable=False, index=True
)
comment_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
page_revision: Mapped[int] = mapped_column(Integer, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
created_by: Mapped[str | None] = mapped_column(
String(255), nullable=True, index=True
)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
idempotency_key: Mapped[str] = mapped_column(String(255), nullable=False)
request_sha256: Mapped[str] = mapped_column(String(64), nullable=False)
__all__ = [
"WikiComment",
"WikiPage",
"WikiPageRevision",
"WikiSpace",
"WikiSpaceHistory",
]
+352
View File
@@ -0,0 +1,352 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import UTC, datetime
from sqlalchemy import or_
from sqlalchemy.orm import Session
from govoplan_core.core.dsar import (
DsarErasureActionRef,
DsarExecutionResultRef,
DsarRecordRef,
DsarSubjectRef,
dsar_capability_name,
)
from govoplan_wiki.backend.db.models import (
WikiComment,
WikiPage,
WikiPageRevision,
WikiSpace,
WikiSpaceHistory,
)
WIKI_DSAR_CAPABILITY = dsar_capability_name("wiki")
_MAX_RECORDS = 5_000
class WikiDsarProvider:
provider_id = "wiki"
module_id = "wiki"
def search_subject(
self, session: object, *, tenant_id: str, subject: DsarSubjectRef
) -> Sequence[DsarRecordRef]:
db = _session(session)
ids = _subject_ids(subject)
if not ids:
return ()
tokens = tuple(
f"{kind}:{value}" for kind, value in _subject_pairs(subject) if value
)
page_ids = set()
page_ids.update(
item[0]
for item in db.query(WikiPage.id)
.filter(
WikiPage.tenant_id == tenant_id,
or_(WikiPage.created_by.in_(ids), WikiPage.updated_by.in_(ids)),
)
.limit(_MAX_RECORDS + 1)
.all()
)
page_ids.update(
item[0]
for item in db.query(WikiPageRevision.page_id)
.filter(
WikiPageRevision.tenant_id == tenant_id,
WikiPageRevision.actor_id.in_(ids),
)
.distinct()
.limit(_MAX_RECORDS + 1)
.all()
)
page_ids.update(
item[0]
for item in db.query(WikiComment.page_id)
.filter(WikiComment.tenant_id == tenant_id, WikiComment.created_by.in_(ids))
.distinct()
.limit(_MAX_RECORDS + 1)
.all()
)
token_filter = or_(*(WikiPage.acl_tokens.contains(token) for token in tokens))
for row in (
db.query(WikiPage)
.filter(WikiPage.tenant_id == tenant_id, token_filter)
.limit(_MAX_RECORDS + 1)
):
if set(row.acl_tokens or ()) & set(tokens):
page_ids.add(row.id)
if len(page_ids) > _MAX_RECORDS:
raise ValueError("Wiki DSAR result limit exceeded; narrow the selectors.")
records = [
self._page_record(db, tenant_id, page_id, ids, tokens)
for page_id in sorted(page_ids)
]
space_token_filter = or_(
*(WikiSpace.acl_tokens.contains(token) for token in tokens)
)
for space in (
db.query(WikiSpace)
.filter(
WikiSpace.tenant_id == tenant_id,
or_(
WikiSpace.created_by.in_(ids),
WikiSpace.updated_by.in_(ids),
space_token_filter,
),
)
.limit(_MAX_RECORDS + 1)
):
actor_match = space.created_by in ids or space.updated_by in ids
acl_match = bool(set(space.acl_tokens or ()) & set(tokens))
if actor_match or acl_match:
records.append(self._space_record(db, space, ids, tokens))
if len(records) > _MAX_RECORDS:
raise ValueError("Wiki DSAR result limit exceeded; narrow the selectors.")
return tuple(records)
def plan_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
records: Sequence[DsarRecordRef],
) -> Sequence[DsarErasureActionRef]:
del tenant_id
_session(session)
if not _subject_ids(subject):
raise ValueError(
"Wiki DSAR requires an exact account, identity, or membership selector."
)
return tuple(
DsarErasureActionRef(
action_id=f"wiki:review:{item.resource_type}:{item.resource_id}",
provider_id=self.provider_id,
module_id=self.module_id,
kind="manual_review",
resource_type=item.resource_type,
resource_id=item.resource_id,
title=f"Review {item.title}",
rationale="Wiki ACL membership and comments may be minimized only after the page owner and applicable retention policy review the institutional knowledge record and immutable revision evidence.",
executable=False,
)
for item in records
if _valid_record(item)
)
def execute_erasure(
self,
session: object,
*,
tenant_id: str,
subject: DsarSubjectRef,
actions: Sequence[DsarErasureActionRef],
request_id: str,
) -> Sequence[DsarExecutionResultRef]:
del tenant_id
_session(session)
if not _subject_ids(subject):
raise ValueError("Wiki DSAR requires an exact selector.")
results = []
for action in actions:
if (
action.provider_id != self.provider_id
or action.module_id != self.module_id
or action.kind != "manual_review"
or action.executable
):
raise ValueError(
"Wiki DSAR exposes non-executable manual-review actions only."
)
results.append(
DsarExecutionResultRef(
action_id=action.action_id,
status="blocked",
summary="Wiki content remains unchanged pending owner and retention review.",
evidence={"request_id": request_id},
)
)
return tuple(results)
def _page_record(
self,
db: Session,
tenant_id: str,
page_id: str,
ids: tuple[str, ...],
tokens: tuple[str, ...],
) -> DsarRecordRef:
row = (
db.query(WikiPage)
.filter(WikiPage.tenant_id == tenant_id, WikiPage.id == page_id)
.one()
)
activities = (
db.query(WikiPageRevision)
.filter(
WikiPageRevision.tenant_id == tenant_id,
WikiPageRevision.page_id == page_id,
WikiPageRevision.actor_id.in_(ids),
)
.order_by(WikiPageRevision.revision)
.limit(500)
.all()
)
comments = (
db.query(WikiComment)
.filter(
WikiComment.tenant_id == tenant_id,
WikiComment.page_id == page_id,
WikiComment.created_by.in_(ids),
)
.order_by(WikiComment.recorded_at)
.limit(500)
.all()
)
return DsarRecordRef(
provider_id="wiki",
module_id="wiki",
resource_type="wiki_page_participation",
resource_id=row.id,
category="collaborative_knowledge",
title=f"Wiki participation: {row.title}",
data={
"space_id": row.space_id,
"page_title": row.title,
"path": row.path,
"revision": row.revision,
"matching_acl_tokens": sorted(set(row.acl_tokens or ()) & set(tokens)),
"subject_activities": [
{
"revision": item.revision,
"event_type": item.event_type,
"recorded_at": _iso(item.recorded_at),
}
for item in activities
],
"subject_comments": [
{
"comment_id": item.comment_id,
"page_revision": item.page_revision,
"body": item.body[:20_000],
"recorded_at": _iso(item.recorded_at),
}
for item in comments
],
},
observed_at=_aware(row.updated_at),
immutable_evidence=True,
retention_reason="Published knowledge and revision attribution can be institutional accountability evidence.",
source_path=f"/wiki?pageId={row.id}",
)
def _space_record(
self, db: Session, row: WikiSpace, ids: tuple[str, ...], tokens: tuple[str, ...]
) -> DsarRecordRef:
history = (
db.query(WikiSpaceHistory)
.filter(
WikiSpaceHistory.tenant_id == row.tenant_id,
WikiSpaceHistory.space_id == row.id,
WikiSpaceHistory.actor_id.in_(ids),
)
.order_by(WikiSpaceHistory.revision)
.limit(500)
.all()
)
return DsarRecordRef(
provider_id="wiki",
module_id="wiki",
resource_type="wiki_space_participation",
resource_id=row.id,
category="collaborative_knowledge_administration",
title=f"Wiki space participation: {row.title}",
data={
"space_key": row.space_key,
"title": row.title,
"matching_acl_tokens": sorted(set(row.acl_tokens or ()) & set(tokens)),
"subject_activities": [
{
"revision": item.revision,
"event_type": item.event_type,
"recorded_at": _iso(item.recorded_at),
}
for item in history
],
},
observed_at=_aware(row.updated_at),
immutable_evidence=True,
retention_reason="Space administration is retained as governance evidence.",
source_path="/wiki",
)
def _subject_pairs(subject: DsarSubjectRef) -> tuple[tuple[str, str], ...]:
refs = subject.external_references
candidates = (
(
"account",
(subject.account_id, refs.get("wiki.account"), refs.get("access.account")),
),
(
"identity",
(subject.identity_id, refs.get("wiki.identity"), refs.get("identity.id")),
),
(
"membership",
(
subject.membership_id,
refs.get("wiki.membership"),
refs.get("tenancy.membership"),
),
),
)
pairs: list[tuple[str, str]] = []
for kind, raw_values in candidates:
values = {
str(value).strip() for value in raw_values if str(value or "").strip()
}
if len(values) > 1:
return ()
if values:
pairs.append((kind, next(iter(values))))
return tuple(pairs)
def _subject_ids(subject: DsarSubjectRef) -> tuple[str, ...]:
return tuple(dict.fromkeys(value for _kind, value in _subject_pairs(subject)))
def _valid_record(item: DsarRecordRef) -> bool:
if (
item.provider_id != "wiki"
or item.module_id != "wiki"
or item.resource_type
not in {"wiki_page_participation", "wiki_space_participation"}
):
raise ValueError("Foreign DSAR record supplied to Wiki.")
return True
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Wiki DSAR requires a SQLAlchemy session.")
return value
def _aware(value: datetime | None) -> datetime | None:
return (
value.replace(tzinfo=UTC)
if value is not None and (value.tzinfo is None or value.utcoffset() is None)
else value
)
def _iso(value: datetime | None) -> str | None:
return _aware(value).isoformat() if value else None
__all__ = ["WIKI_DSAR_CAPABILITY", "WikiDsarProvider"]
+471 -48
View File
@@ -1,25 +1,58 @@
from __future__ import annotations
from pathlib import Path
from sqlalchemy import func
from govoplan_core.core.access import (
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
)
from govoplan_core.core.module_guards import (
drop_table_retirement_provider,
persistent_table_uninstall_guard,
)
from govoplan_core.core.modules import (
CapabilityDocumentation,
DocumentationCondition,
DocumentationLink,
DocumentationTopic,
FrontendModule,
FrontendRoute,
MigrationSpec,
ModuleContext,
ModuleInterfaceProvider,
ModuleManifest,
NavItem,
PermissionDefinition,
ProductAreaContribution,
RoleTemplate,
)
from govoplan_core.core.provider_governance import declared_module_architecture
from govoplan_core.core.provider_governance import (
ModuleArchitectureDeclaration,
ModuleArchitectureDocumentation,
ModuleMaturityEvidence,
)
from govoplan_core.core.search import SearchSourceProviderRegistration
from govoplan_core.core.views import ViewSurface
from govoplan_core.db.base import Base
from govoplan_wiki.backend.db import models as wiki_models
from govoplan_wiki.backend.dsar_provider import WIKI_DSAR_CAPABILITY, WikiDsarProvider
from govoplan_wiki.backend.search_source import create_wiki_search_source
from govoplan_wiki.backend.service import (
ADMIN_SCOPE,
CAPABILITY_WIKI_REGISTRY,
COMMENT_SCOPE,
PUBLISH_SCOPE,
READ_SCOPE,
SqlWikiRegistry,
WRITE_SCOPE,
)
MODULE_ID = "wiki"
MODULE_NAME = "Wiki"
MODULE_VERSION = "0.1.19"
READ_SCOPE = "wiki:page:read"
WRITE_SCOPE = "wiki:page:write"
ADMIN_SCOPE = "wiki:space:admin"
MODULE_VERSION = "0.1.20"
OPTIONAL_DEPENDENCIES = (
"files",
"dms",
@@ -31,14 +64,49 @@ OPTIONAL_DEPENDENCIES = (
"cases",
"templates",
"notifications",
"connectors",
"policy",
)
def _permission(
scope: str,
label: str,
description: str,
) -> PermissionDefinition:
def _router(context: ModuleContext):
from govoplan_wiki.backend.router import create_router
return create_router(context.registry)
def _registry(_context: ModuleContext) -> SqlWikiRegistry:
return SqlWikiRegistry()
def _dsar(_context: ModuleContext) -> WikiDsarProvider:
return WikiDsarProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
states = {
state: int(count)
for state, count in session.query(wiki_models.WikiPage.state, func.count())
.filter(wiki_models.WikiPage.tenant_id == tenant_id)
.group_by(wiki_models.WikiPage.state)
.all()
}
return {
"wiki_spaces": int(
session.query(func.count(wiki_models.WikiSpace.id))
.filter(wiki_models.WikiSpace.tenant_id == tenant_id)
.scalar()
or 0
),
"wiki_pages": sum(states.values()),
"published_wiki_pages": int(
states.get("published", 0) + states.get("redirected", 0)
),
"draft_wiki_pages": int(states.get("draft", 0)),
}
def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
module_id, resource, action = scope.split(":", 2)
return PermissionDefinition(
scope=scope,
@@ -53,50 +121,84 @@ def _permission(
PERMISSIONS = (
_permission(READ_SCOPE, "Read Wiki pages", "Discover and read accessible Wiki pages."),
_permission(WRITE_SCOPE, "Edit Wiki pages", "Create and revise pages in writable spaces."),
_permission(ADMIN_SCOPE, "Administer Wiki spaces", "Configure spaces, publishing, and access."),
_permission(
READ_SCOPE,
"Read Wiki pages",
"Discover and read accessible published Wiki pages.",
),
_permission(
WRITE_SCOPE,
"Edit Wiki pages",
"Create and revise pages in accessible Wiki spaces.",
),
_permission(
COMMENT_SCOPE,
"Comment on Wiki pages",
"Add immutable comments to accessible Wiki pages.",
),
_permission(
PUBLISH_SCOPE,
"Publish Wiki pages",
"Publish, redirect, and archive governed Wiki pages.",
),
_permission(
ADMIN_SCOPE,
"Administer Wiki spaces",
"Configure Wiki spaces, access inheritance, and publishing policy.",
),
)
ROLE_TEMPLATES = (
RoleTemplate(
slug="wiki_editor",
name="Wiki editor",
description="Read and revise Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE),
),
RoleTemplate(
slug="wiki_reader",
name="Wiki reader",
description="Read accessible Wiki pages.",
description="Read accessible published Wiki pages.",
permissions=(READ_SCOPE,),
),
RoleTemplate(
slug="wiki_editor",
name="Wiki editor",
description="Read, revise, and comment on accessible Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE),
),
RoleTemplate(
slug="wiki_publisher",
name="Wiki publisher",
description="Revise, comment on, publish, and archive accessible Wiki pages.",
permissions=(READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE, PUBLISH_SCOPE),
),
RoleTemplate(
slug="wiki_administrator",
name="Wiki administrator",
description="Configure spaces and govern the complete Wiki lifecycle.",
permissions=(
READ_SCOPE,
WRITE_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
ADMIN_SCOPE,
),
),
)
DOCUMENTATION = (
DocumentationTopic(
id="wiki.module-boundary",
title="Wiki module boundary",
summary=(
"Collaborative knowledge spaces, pages, revisions, links, labels, "
"access, and publishing."
),
body=(
"Wiki owns revisioned knowledge pages. Files/DMS owns binary "
"attachments, Records owns formal retention, and connectors own "
"MediaWiki/BlueSpice synchronization."
),
summary="Governed collaborative knowledge spaces, hierarchical pages, immutable revisions, links, labels, comments, access, and publishing.",
body="Wiki owns native knowledge-space and page identity, hierarchy, drafts, immutable revisions, comments, labels, typed references, redirects, and publication state. Files or DMS owns binary content, Records owns formal record disposition, Docs owns product and configured-system guidance, and Connectors owns MediaWiki or BlueSpice transport.",
layer="available",
documentation_types=("admin", "user"),
audience=("user", "operator", "module_admin", "product_owner"),
related_modules=OPTIONAL_DEPENDENCIES,
translations={
"de": {
"title": "Modulgrenze von Wiki",
"summary": "Gemeinsam bearbeitete Wissensbereiche, Seiten, Revisionen, Verknüpfungen, Schlagwörter, Zugriff und Veröffentlichung.",
"body": "Wiki verwaltet versionierte Wissensseiten. Files beziehungsweise DMS verwaltet binäre Anhänge, Records die formale Aufbewahrung und Connectors die Synchronisierung mit MediaWiki oder BlueSpice.",
"summary": "Gesteuerte Wissensbereiche mit hierarchischen Seiten, unveränderlichen Revisionen, Verweisen, Schlagwörtern, Kommentaren, Zugriff und Veröffentlichung.",
"body": "Wiki verwaltet die Identität nativer Wissensbereiche und Seiten, Hierarchie, Entwürfe, unveränderliche Revisionen, Kommentare, Schlagwörter, typisierte Verweise, Weiterleitungen und Veröffentlichungsstatus. Files beziehungsweise DMS verwaltet Binärinhalte, Records die formale Aufbewahrung, Docs die Produkt- und Konfigurationsdokumentation und Connectors den Transport zu MediaWiki oder BlueSpice.",
}
},
related_modules=OPTIONAL_DEPENDENCIES,
links=(
DocumentationLink(
label="Repository domain boundary",
@@ -106,26 +208,186 @@ DOCUMENTATION = (
),
metadata={
"kind": "reference",
"seed": True,
"consequence_classes": {
"seed_boundary": "Declares ownership and permissions only; no runtime workflow is available yet.",
},
"domain_objects": [
"wiki space",
"wiki page",
"page revision",
"page comment",
"page link",
"page label",
"publishing state",
],
"first_slice": (
"Implement spaces, revisioned pages, links, access checks, "
"and permission-aware search publication."
"consequence_classes": {
"ownership": "Wiki stores governed text and references, never referenced binary content or external transport credentials."
},
},
),
DocumentationTopic(
id="wiki.workflow.author-publish",
title="Author and publish Wiki knowledge",
summary="Browse a space tree, draft pages, compare immutable revisions, publish reviewed content, redirect successors, comment, and archive obsolete knowledge.",
body="Editors create a draft below an accessible parent, add labels and typed links, and save with the revision they loaded. A stale revision fails instead of overwriting newer work. Publishing appends another immutable revision. Later edits become a new draft while readers and Search continue to receive the last published revision. Publishers can redirect a page to another page in the same space or archive it after active child pages are moved or archived. Comments are append-only and record the page revision they discuss.",
layer="available",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator"),
related_modules=("search", "files", "dms"),
conditions=(
DocumentationCondition(
any_scopes=(
READ_SCOPE,
WRITE_SCOPE,
COMMENT_SCOPE,
PUBLISH_SCOPE,
ADMIN_SCOPE,
)
),
),
translations={
"de": {
"title": "Wiki-Wissen bearbeiten und veröffentlichen",
"summary": "Bereichsbaum durchsuchen, Seiten entwerfen, unveränderliche Revisionen vergleichen, geprüfte Inhalte veröffentlichen, Nachfolger weiterleiten, kommentieren und veraltetes Wissen archivieren.",
"body": "Bearbeitende erstellen unter einer zugänglichen übergeordneten Seite einen Entwurf, ergänzen Schlagwörter und typisierte Verweise und speichern mit der geladenen Revision. Eine veraltete Revision überschreibt keine neuere Arbeit, sondern erzeugt einen Konflikt. Die Veröffentlichung fügt eine weitere unveränderliche Revision an. Spätere Änderungen werden zu einem neuen Entwurf, während Lesende und Search weiterhin die zuletzt veröffentlichte Revision erhalten. Veröffentlichende können auf eine andere Seite desselben Bereichs weiterleiten oder eine Seite archivieren, nachdem aktive Unterseiten verschoben oder archiviert wurden. Kommentare werden nur angefügt und halten die besprochene Seitenrevision fest.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["wiki.workspace", "wiki.editor", "wiki.revisions"],
"consequence_classes": {
"stale_revision": "The write returns a conflict and preserves both the stored revision and the caller's draft.",
"draft_after_publish": "The last published revision remains reader-visible until the new draft is explicitly published.",
},
},
),
DocumentationTopic(
id="wiki.admin.access-publishing",
title="Configure Wiki access and publishing",
summary="Separate read, edit, comment, publish, and space-administration authority; inherit or override tenant-safe ACLs; choose publisher-only or editor publishing.",
body="Every operation requires a tenant-bound principal and the matching Wiki permission. A restricted space or page uses exact account, identity, membership, group, role, or function-assignment tokens. Pages inherit their space access by default; an explicit page override can be tenant-visible or restricted but must not be empty when restricted. Space administrators bypass local ACLs for governance. The publishers mode requires the publish permission; the editors mode also lets a permitted editor publish. Archived spaces accept no new or revised content.",
layer="available",
documentation_types=("admin",),
audience=("module_admin", "operator", "product_owner"),
related_modules=("access", "policy", "tenancy"),
translations={
"de": {
"title": "Wiki-Zugriff und Veröffentlichung konfigurieren",
"summary": "Lese-, Bearbeitungs-, Kommentar-, Veröffentlichungs- und Bereichsadministration trennen, mandantensichere ACLs erben oder überschreiben und Veröffentlichung durch Veröffentlichende oder Bearbeitende wählen.",
"body": "Jeder Vorgang erfordert einen mandantengebundenen Akteur und die passende Wiki-Berechtigung. Ein eingeschränkter Bereich oder eine Seite verwendet exakte Kennungen für Konto, Identität, Mitgliedschaft, Gruppe, Rolle oder Funktionszuweisung. Seiten erben standardmäßig den Zugriff ihres Bereichs; eine ausdrückliche Seitenregel kann mandantenweit oder eingeschränkt sein, darf bei eingeschränktem Zugriff aber nicht leer sein. Bereichsadministrierende umgehen lokale ACLs für die Governance. Im Modus „publishers“ ist die Veröffentlichungsberechtigung erforderlich; im Modus „editors“ dürfen auch berechtigte Bearbeitende veröffentlichen. Archivierte Bereiche nehmen keine neuen oder geänderten Inhalte an.",
}
},
metadata={
"kind": "admin",
"help_contexts": ["wiki.admin.space", "wiki.admin.permissions"],
"consequence_classes": {
"restricted_without_acl": "Configuration is rejected to prevent an unreachable or accidentally exposed resource.",
"publisher_mode": "Publishing authority is evaluated again at the action boundary.",
},
},
),
DocumentationTopic(
id="wiki.integrations.references-search",
title="Use Wiki references and optional integrations",
summary="Link Files, DMS, Projects, Cases, Tasks, templates, workflows, and external pages without importing their lifecycles; publish ACL-aware Search documents.",
body="Wiki links store the owning module, resource type and identifier, relationship, optional safe URL, and external provenance. Attachment links may name Files or DMS only; Wiki stores no binary bytes. Published revisions become global Search documents with effective visibility and ACL tokens and are authorization-rechecked against current Wiki state. Drafts and archives are never indexed. Without Search, content remains browsable in Wiki. Without Files or DMS, existing references remain visible but upload and binary resolution belong to the absent provider. External Wiki transport, credentials, reconciliation, and migration execution remain connector-owned.",
layer="available",
documentation_types=("user", "admin"),
audience=("user", "module_admin", "operator", "integrator"),
related_modules=OPTIONAL_DEPENDENCIES,
conditions=(
DocumentationCondition(any_scopes=(READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE)),
),
translations={
"de": {
"title": "Wiki-Verweise und optionale Integrationen verwenden",
"summary": "Files, DMS, Projects, Cases, Tasks, Vorlagen, Workflows und externe Seiten verknüpfen, ohne deren Lebenszyklen zu importieren, und ACL-geschützte Suchdokumente veröffentlichen.",
"body": "Wiki-Verweise speichern das besitzende Modul, Ressourcentyp und Kennung, die Beziehung, eine optionale sichere URL und externe Herkunft. Anhangsverweise dürfen nur Files oder DMS nennen; Wiki speichert keine Binärdaten. Veröffentlichte Revisionen werden mit effektiver Sichtbarkeit und ACL-Kennungen an die globale Suche übergeben und gegen den aktuellen Wiki-Zugriff erneut geprüft. Entwürfe und Archive werden nie indiziert. Ohne Search bleiben Inhalte im Wiki auffindbar. Ohne Files oder DMS bleiben vorhandene Verweise sichtbar; Hochladen und Auflösen von Binärinhalten gehören jedoch zum fehlenden Anbieter. Transport, Zugangsdaten, Abgleich und Migrationsausführung für externe Wikis verbleiben bei Connectors.",
}
},
metadata={
"kind": "workflow",
"help_contexts": ["wiki.editor.links", "wiki.admin.integrations"],
"consequence_classes": {
"search_absent": "Published pages are not discoverable through global Search.",
"files_absent": "References remain metadata-only and no binary operation is offered.",
"connector_absent": "External references can be recorded, but synchronization or migration cannot run.",
},
},
),
DocumentationTopic(
id="wiki.admin.data-subject-requests",
title="Retain, export, and retire Wiki evidence",
summary="Treat revisions and comments as governed institutional evidence and review personal references manually before minimization or destructive retirement.",
body="The Wiki DSAR provider matches exact account, identity, and membership identifiers in actor attribution, ACL tokens, and the subject's own comments. It returns bounded page or space context, the subject's actions and comments, and no unrelated comments, arbitrary request metadata, or hidden draft bodies. Erasure is never automatic: the knowledge owner and retention authority decide whether ACL membership or comments can be detached or minimized while immutable attribution is retained. Uninstall is blocked while Wiki rows exist. Destructive retirement requires an explicit database snapshot and retention review and removes only Wiki-owned tables; referenced Files, DMS, Records, or connector resources remain owned by their modules.",
layer="available",
documentation_types=("admin",),
audience=("privacy", "records_manager", "operator", "module_admin"),
related_modules=("records", "files", "dms", "connectors"),
translations={
"de": {
"title": "Wiki-Nachweise aufbewahren, exportieren und stilllegen",
"summary": "Revisionen und Kommentare als gesteuerte institutionelle Nachweise behandeln und Personenbezüge vor Minimierung oder destruktiver Stilllegung manuell prüfen.",
"body": "Der Wiki-DSAR-Anbieter gleicht exakte Konto-, Identitäts- und Mitgliedschaftskennungen in Akteurzuordnung, ACL-Kennungen und eigenen Kommentaren der betroffenen Person ab. Er liefert begrenzten Seiten- oder Bereichskontext, eigene Handlungen und Kommentare, jedoch keine fremden Kommentare, beliebigen Anfragemetadaten oder verborgenen Entwurfsinhalte. Eine Löschung erfolgt nie automatisch: Wissensverantwortliche und Aufbewahrungsstelle entscheiden, ob ACL-Mitgliedschaft oder Kommentare gelöst beziehungsweise minimiert werden dürfen, während unveränderliche Zuordnung erhalten bleibt. Eine Deinstallation ist blockiert, solange Wiki-Daten vorhanden sind. Destruktive Stilllegung erfordert eine ausdrückliche Datenbanksicherung und Aufbewahrungsprüfung und entfernt nur Wiki-eigene Tabellen; referenzierte Ressourcen in Files, DMS, Records oder Connectors verbleiben bei deren Modulen.",
}
},
metadata={
"kind": "admin",
"help_contexts": ["wiki.admin.dsar", "wiki.admin.retirement"],
"consequence_classes": {
"erasure": "The provider creates non-executable manual-review actions and changes no knowledge evidence automatically.",
"uninstall": "Persistent Wiki rows block uninstall until migration, retention, or explicit retirement is complete.",
},
},
),
)
ARCHITECTURE = ModuleArchitectureDeclaration(
layer="content_records_evidence",
kind="domain",
maturity="vertical_slice",
evidence=(
ModuleMaturityEvidence(
kind="documentation",
reference="docs/WIKI_DOMAIN_BOUNDARY.md",
summary="Defines native Wiki, Docs, Files/DMS, Records, Search, and external-transport boundaries.",
),
ModuleMaturityEvidence(
kind="test",
reference="tests/test_wiki_service.py",
summary="Proves hierarchy, immutable publication, OCC, replay safety, ACLs, comments, references, Search, and tenant isolation.",
),
),
known_limits=(
"External MediaWiki and BlueSpice transport and migration execution require a connector provider.",
"The first editor stores governed plain text; rich-text and collaborative presence are later UI capabilities over the same immutable revision contract.",
"Moving or renaming a page with active children requires moving those children first so paths never change silently.",
),
supported_authority_modes=(
"native_authoritative",
"external_mirror",
"governed_sync",
"linked_reference",
),
owned_concepts=(
"wiki space",
"wiki page hierarchy",
"page revision",
"page comment",
"page link",
"page label",
"publishing state",
),
non_owned_concepts=(
"binary attachment content",
"formal record disposition",
"product documentation",
"external Wiki transport",
),
documentation=ModuleArchitectureDocumentation(
operations=("docs/WIKI_DOMAIN_BOUNDARY.md",)
),
)
manifest = ModuleManifest(
id=MODULE_ID,
name=MODULE_NAME,
@@ -136,18 +398,179 @@ manifest = ModuleManifest(
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
),
provides_interfaces=(
ModuleInterfaceProvider(name=CAPABILITY_WIKI_REGISTRY, version="1.0.0"),
ModuleInterfaceProvider(name=WIKI_DSAR_CAPABILITY, version="0.1.0"),
),
permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES,
documentation=DOCUMENTATION,
architecture=declared_module_architecture(
layer="content_records_evidence",
kind="domain",
maturity="scaffold",
documentation_ref="docs/WIKI_DOMAIN_BOUNDARY.md",
known_limits=("Wiki spaces, pages, revisions, publishing, and search projection are not implemented yet.",),
owned_concepts=("wiki space", "wiki page", "page revision", "page link"),
non_owned_concepts=("binary attachment", "record disposition", "external MediaWiki page"),
route_factory=_router,
nav_items=(
NavItem(
path="/wiki",
label="Wiki",
icon="book-open",
required_any=(READ_SCOPE,),
order=54,
surface_id="wiki.navigation",
),
),
frontend=FrontendModule(
module_id=MODULE_ID,
package_name="@govoplan/wiki-webui",
routes=(
FrontendRoute(
path="/wiki",
component="WikiPage",
required_any=(READ_SCOPE,),
order=54,
surface_id="wiki.route.workspace",
),
),
nav_items=(
NavItem(
path="/wiki",
label="Wiki",
icon="book-open",
required_any=(READ_SCOPE,),
order=54,
surface_id="wiki.navigation",
),
),
product_areas=(
ProductAreaContribution(
id="records-documents",
module_id=MODULE_ID,
label="i18n:govoplan-core.product_area.records_documents",
icon="folder",
description="i18n:govoplan-core.product_area.records_documents_description",
surface_ids=("wiki.route.workspace",),
order=30,
),
),
view_surfaces=(
ViewSurface(
id="wiki.section.space-tree",
module_id=MODULE_ID,
kind="section",
label="Wiki spaces and page tree",
parent_id="wiki.route.workspace",
order=10,
),
ViewSurface(
id="wiki.section.page",
module_id=MODULE_ID,
kind="section",
label="Wiki page",
parent_id="wiki.route.workspace",
order=20,
),
ViewSurface(
id="wiki.action.create-space",
module_id=MODULE_ID,
kind="action",
label="Create Wiki space",
parent_id="wiki.section.space-tree",
order=30,
),
ViewSurface(
id="wiki.action.create-page",
module_id=MODULE_ID,
kind="action",
label="Create Wiki page",
parent_id="wiki.section.space-tree",
order=40,
),
ViewSurface(
id="wiki.action.edit",
module_id=MODULE_ID,
kind="action",
label="Edit Wiki page",
parent_id="wiki.section.page",
order=50,
),
ViewSurface(
id="wiki.action.publish",
module_id=MODULE_ID,
kind="action",
label="Publish Wiki page",
parent_id="wiki.section.page",
order=60,
),
ViewSurface(
id="wiki.action.comment",
module_id=MODULE_ID,
kind="action",
label="Comment on Wiki page",
parent_id="wiki.section.page",
order=70,
),
ViewSurface(
id="wiki.section.revisions",
module_id=MODULE_ID,
kind="section",
label="Wiki page revisions",
parent_id="wiki.section.page",
order=80,
),
ViewSurface(
id="wiki.action.archive",
module_id=MODULE_ID,
kind="action",
label="Archive Wiki content",
parent_id="wiki.section.page",
order=90,
),
),
),
tenant_summary_providers=(_tenant_summary,),
capability_factories={
CAPABILITY_WIKI_REGISTRY: _registry,
WIKI_DSAR_CAPABILITY: _dsar,
},
capability_documentation={
CAPABILITY_WIKI_REGISTRY: CapabilityDocumentation(
label="Wiki registry",
summary="Resolves an accessible Wiki page to a stable cross-module reference without exposing implementation internals.",
contract_version="1.0.0",
),
WIKI_DSAR_CAPABILITY: CapabilityDocumentation(
label="Wiki data-subject request provider",
summary="Exports bounded exact-identifier Wiki participation and produces manual-review erasure actions.",
contract_version="0.1.0",
),
},
search_sources=(
SearchSourceProviderRegistration(
id="wiki.pages", factory=create_wiki_search_source
),
),
migration_spec=MigrationSpec(
module_id=MODULE_ID,
metadata=Base.metadata,
script_location=str(Path(__file__).with_name("migrations") / "versions"),
retirement_supported=True,
retirement_provider=drop_table_retirement_provider(
wiki_models.WikiComment,
wiki_models.WikiPageRevision,
wiki_models.WikiPage,
wiki_models.WikiSpaceHistory,
wiki_models.WikiSpace,
label="Wiki",
),
retirement_notes="Destructive retirement removes Wiki spaces, pages, immutable revisions, and comments only after an explicit database snapshot and retention review; referenced resources remain module-owned.",
),
uninstall_guard_providers=(
persistent_table_uninstall_guard(
wiki_models.WikiSpace,
wiki_models.WikiPage,
wiki_models.WikiPageRevision,
wiki_models.WikiComment,
label="Wiki",
),
),
documentation=DOCUMENTATION,
architecture=ARCHITECTURE,
)
@@ -0,0 +1 @@
"""Wiki Alembic migrations."""
@@ -0,0 +1 @@
"""Wiki migration revisions."""
@@ -0,0 +1,261 @@
"""v0.1.20 governed Wiki vertical slice.
Revision ID: a7c2e9f4b1d6
Revises: None
"""
from __future__ import annotations
from alembic import op
import sqlalchemy as sa
revision = "a7c2e9f4b1d6"
down_revision = None
branch_labels = None
depends_on = "4f2a9c8e7b6d"
def upgrade() -> None:
op.create_table(
"wiki_spaces",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_key", sa.String(length=120), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("description", sa.Text(), nullable=False),
sa.Column("visibility", sa.String(length=40), nullable=False),
sa.Column("acl_tokens", sa.JSON(), nullable=False),
sa.Column("publish_mode", sa.String(length=40), nullable=False),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_spaces")),
sa.UniqueConstraint("tenant_id", "space_key", name="uq_wiki_space_key"),
)
for column in (
"tenant_id",
"space_key",
"visibility",
"archived_at",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_wiki_spaces_{column}"), "wiki_spaces", [column], unique=False
)
op.create_index(
"ix_wiki_space_catalog",
"wiki_spaces",
["tenant_id", "archived_at", "title"],
unique=False,
)
op.create_table(
"wiki_space_history",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["space_id"],
["wiki_spaces.id"],
name=op.f("fk_wiki_space_history_space_id_wiki_spaces"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_space_history")),
sa.UniqueConstraint(
"tenant_id", "space_id", "revision", name="uq_wiki_space_history_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_space_history_idempotency"
),
)
for column in ("tenant_id", "space_id", "event_type", "recorded_at", "actor_id"):
op.create_index(
op.f(f"ix_wiki_space_history_{column}"),
"wiki_space_history",
[column],
unique=False,
)
op.create_index(
"ix_wiki_space_history_timeline",
"wiki_space_history",
["tenant_id", "space_id", "recorded_at"],
unique=False,
)
op.create_table(
"wiki_pages",
sa.Column("id", sa.String(length=255), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("space_id", sa.String(length=255), nullable=False),
sa.Column("parent_page_id", sa.String(length=255), nullable=True),
sa.Column("slug", sa.String(length=160), nullable=False),
sa.Column("path", sa.String(length=1000), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("published_revision", sa.Integer(), nullable=True),
sa.Column("state", sa.String(length=40), nullable=False),
sa.Column("title", sa.String(length=500), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("visibility", sa.String(length=40), nullable=False),
sa.Column("inherits_access", sa.Boolean(), nullable=False),
sa.Column("acl_tokens", sa.JSON(), nullable=False),
sa.Column("labels", sa.JSON(), nullable=False),
sa.Column("links", sa.JSON(), nullable=False),
sa.Column("redirect_page_id", sa.String(length=255), nullable=True),
sa.Column("search_text", sa.Text(), nullable=False),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("updated_by", sa.String(length=255), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["space_id"],
["wiki_spaces.id"],
name=op.f("fk_wiki_pages_space_id_wiki_spaces"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["parent_page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_pages_parent_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.ForeignKeyConstraint(
["redirect_page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_pages_redirect_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_pages")),
sa.UniqueConstraint("tenant_id", "space_id", "path", name="uq_wiki_page_path"),
)
for column in (
"tenant_id",
"space_id",
"parent_page_id",
"state",
"visibility",
"published_at",
"archived_at",
"created_by",
"updated_by",
):
op.create_index(
op.f(f"ix_wiki_pages_{column}"), "wiki_pages", [column], unique=False
)
op.create_index(
"ix_wiki_page_tree",
"wiki_pages",
["tenant_id", "space_id", "parent_page_id", "state"],
unique=False,
)
op.create_index(
"ix_wiki_page_catalog",
"wiki_pages",
["tenant_id", "state", "updated_at"],
unique=False,
)
op.create_table(
"wiki_page_revisions",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("page_id", sa.String(length=255), nullable=False),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("event_type", sa.String(length=80), nullable=False),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_id", sa.String(length=255), nullable=True),
sa.Column("change_reason", sa.String(length=1000), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("snapshot", sa.JSON(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_page_revisions_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_page_revisions")),
sa.UniqueConstraint(
"tenant_id", "page_id", "revision", name="uq_wiki_page_revision"
),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_page_idempotency"
),
)
for column in ("tenant_id", "page_id", "event_type", "recorded_at", "actor_id"):
op.create_index(
op.f(f"ix_wiki_page_revisions_{column}"),
"wiki_page_revisions",
[column],
unique=False,
)
op.create_index(
"ix_wiki_page_revision_timeline",
"wiki_page_revisions",
["tenant_id", "page_id", "recorded_at"],
unique=False,
)
op.create_table(
"wiki_comments",
sa.Column("id", sa.String(length=36), nullable=False),
sa.Column("tenant_id", sa.String(length=255), nullable=False),
sa.Column("page_id", sa.String(length=255), nullable=False),
sa.Column("comment_id", sa.String(length=255), nullable=False),
sa.Column("page_revision", sa.Integer(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("created_by", sa.String(length=255), nullable=True),
sa.Column("recorded_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("idempotency_key", sa.String(length=255), nullable=False),
sa.Column("request_sha256", sa.String(length=64), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["page_id"],
["wiki_pages.id"],
name=op.f("fk_wiki_comments_page_id_wiki_pages"),
ondelete="RESTRICT",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_wiki_comments")),
sa.UniqueConstraint("tenant_id", "comment_id", name="uq_wiki_comment"),
sa.UniqueConstraint(
"tenant_id", "idempotency_key", name="uq_wiki_comment_idempotency"
),
)
for column in ("tenant_id", "page_id", "comment_id", "created_by", "recorded_at"):
op.create_index(
op.f(f"ix_wiki_comments_{column}"), "wiki_comments", [column], unique=False
)
op.create_index(
"ix_wiki_comment_timeline",
"wiki_comments",
["tenant_id", "page_id", "recorded_at"],
unique=False,
)
def downgrade() -> None:
op.drop_table("wiki_comments")
op.drop_table("wiki_page_revisions")
op.drop_table("wiki_pages")
op.drop_table("wiki_space_history")
op.drop_table("wiki_spaces")
+361
View File
@@ -0,0 +1,361 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from govoplan_core.auth import ApiPrincipal, get_api_principal
from govoplan_core.core.concurrency import strong_resource_etag
from govoplan_core.db.session import get_session
from govoplan_wiki.backend.schemas import (
CommentRequest,
MutationRequest,
PageCreateRequest,
PageUpdateRequest,
SpaceCreateRequest,
SpaceUpdateRequest,
)
from govoplan_wiki.backend.service import (
WikiConflictError,
WikiNotFoundError,
WikiStoreError,
add_comment,
archive_page,
archive_space,
compare_revisions,
create_page,
create_space,
get_page,
get_space,
integration_availability,
list_comments,
list_pages,
list_spaces,
page_revisions,
publish_page,
update_page,
update_space,
)
def create_router(registry: object | None) -> APIRouter:
router = APIRouter(prefix="/wiki", tags=["wiki"])
@router.get("/availability")
def api_availability(
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
del principal
return integration_availability(registry)
@router.get("/spaces")
def api_list_spaces(
include_archived: bool = False,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return {
"spaces": list(
list_spaces(session, principal, include_archived=include_archived)
)
}
@router.post("/spaces", status_code=status.HTTP_201_CREATED)
def api_create_space(
payload: SpaceCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_space",
lambda: create_space(
session, principal, registry=registry, **payload.model_dump()
),
)
@router.get("/spaces/{space_id}")
def api_get_space(
space_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
item = get_space(session, principal, space_id=space_id)
if item is None:
raise HTTPException(status_code=404, detail="Wiki space not found")
_etag(response, "wiki_space", item)
return item
@router.patch("/spaces/{space_id}")
def api_update_space(
space_id: str,
payload: SpaceUpdateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_space",
lambda: update_space(
session,
principal,
space_id=space_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/spaces/{space_id}/archive")
def api_archive_space(
space_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
values = payload.model_dump()
values.pop("changes", None)
return _write(
session,
response,
"wiki_space",
lambda: archive_space(
session, principal, space_id=space_id, registry=registry, **values
),
)
@router.get("/pages")
def api_list_pages(
space_id: str | None = None,
parent_page_id: str | None = None,
page_state: list[str] | None = Query(default=None, alias="state"),
query: str = Query(default="", max_length=500),
include_archived: bool = False,
offset: int = Query(default=0, ge=0),
limit: int = Query(default=100, ge=1, le=200),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
pages, total = list_pages(
session,
principal,
space_id=space_id,
parent_page_id=parent_page_id,
states=page_state or (),
query=query,
include_archived=include_archived,
offset=offset,
limit=limit,
)
return {"pages": list(pages), "total": total, "offset": offset, "limit": limit}
@router.post("/pages", status_code=status.HTTP_201_CREATED)
def api_create_page(
payload: PageCreateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: create_page(
session, principal, registry=registry, **payload.model_dump()
),
)
@router.get("/pages/{page_id}")
def api_get_page(
page_id: str,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
item = get_page(session, principal, page_id=page_id)
if item is None:
raise HTTPException(status_code=404, detail="Wiki page not found")
_etag(response, "wiki_page", item)
return item
@router.patch("/pages/{page_id}")
def api_update_page(
page_id: str,
payload: PageUpdateRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: update_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/pages/{page_id}/publish")
def api_publish_page(
page_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: publish_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.post("/pages/{page_id}/archive")
def api_archive_page(
page_id: str,
payload: MutationRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
return _write(
session,
response,
"wiki_page",
lambda: archive_page(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
),
)
@router.get("/pages/{page_id}/revisions")
def api_page_revisions(
page_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return {
"revisions": list(page_revisions(session, principal, page_id=page_id))
}
except Exception as exc:
raise _error(exc) from exc
@router.get("/pages/{page_id}/compare")
def api_compare_revisions(
page_id: str,
from_revision: int = Query(ge=1),
to_revision: int = Query(ge=1),
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return compare_revisions(
session,
principal,
page_id=page_id,
from_revision=from_revision,
to_revision=to_revision,
)
except Exception as exc:
raise _error(exc) from exc
@router.get("/pages/{page_id}/comments")
def api_list_comments(
page_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
return {
"comments": list(list_comments(session, principal, page_id=page_id))
}
except Exception as exc:
raise _error(exc) from exc
@router.post("/pages/{page_id}/comments")
def api_add_comment(
page_id: str,
payload: CommentRequest,
response: Response,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> dict[str, object]:
try:
comment = add_comment(
session,
principal,
page_id=page_id,
registry=registry,
**payload.model_dump(),
)
session.commit()
response.headers["ETag"] = strong_resource_etag(
"wiki_page", page_id, int(comment["page_revision"])
)
return comment
except Exception as exc:
session.rollback()
raise _error(exc) from exc
return router
def _write(
session: Session, response: Response, resource: str, operation
) -> dict[str, object]:
try:
item = operation()
session.commit()
except Exception as exc:
session.rollback()
raise _error(exc) from exc
_etag(response, resource, item)
return item
def _etag(response: Response, resource: str, item: dict[str, object]) -> None:
resource_id = str(item["page_id"] if resource == "wiki_page" else item["space_id"])
response.headers["ETag"] = strong_resource_etag(
resource, resource_id, int(item["revision"])
)
def _error(exc: Exception) -> HTTPException:
if isinstance(exc, HTTPException):
return exc
if isinstance(exc, WikiNotFoundError):
code = 404
elif isinstance(exc, PermissionError):
code = 403
elif (
isinstance(exc, (WikiConflictError, IntegrityError))
or "conflict" in str(exc).casefold()
):
code = 409
elif isinstance(exc, WikiStoreError):
code = 400
else:
code = 500
return HTTPException(status_code=code, detail=str(exc))
__all__ = ["create_router"]
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
class MutationRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class SpaceCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
space_id: str = Field(min_length=1, max_length=255)
space_key: str = Field(min_length=1, max_length=120)
title: str = Field(min_length=1, max_length=500)
description: str = Field(default="", max_length=20_000)
visibility: str = Field(default="tenant", max_length=40)
acl_tokens: list[str] = Field(default_factory=list, max_length=500)
publish_mode: str = Field(default="publishers", max_length=40)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class SpaceUpdateRequest(MutationRequest):
changes: dict[str, Any]
class PageCreateRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
page_id: str = Field(min_length=1, max_length=255)
space_id: str = Field(min_length=1, max_length=255)
parent_page_id: str | None = Field(default=None, max_length=255)
slug: str = Field(min_length=1, max_length=160)
title: str = Field(min_length=1, max_length=500)
body: str = Field(default="", max_length=500_000)
summary: str = Field(default="", max_length=10_000)
inherits_access: bool = True
visibility: str = Field(default="tenant", max_length=40)
acl_tokens: list[str] = Field(default_factory=list, max_length=500)
labels: list[str] = Field(default_factory=list, max_length=50)
links: list[dict[str, Any]] = Field(default_factory=list, max_length=200)
recorded_at: datetime
change_reason: str = Field(min_length=1, max_length=1_000)
idempotency_key: str = Field(min_length=1, max_length=255)
class PageUpdateRequest(MutationRequest):
changes: dict[str, Any]
class CommentRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
expected_revision: int = Field(ge=1)
comment_id: str = Field(min_length=1, max_length=255)
body: str = Field(min_length=1, max_length=20_000)
recorded_at: datetime
idempotency_key: str = Field(min_length=1, max_length=255)
__all__ = [
"CommentRequest",
"MutationRequest",
"PageCreateRequest",
"PageUpdateRequest",
"SpaceCreateRequest",
"SpaceUpdateRequest",
]
+264
View File
@@ -0,0 +1,264 @@
from __future__ import annotations
from collections.abc import Mapping, Sequence
from urllib.parse import quote
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from govoplan_core.core.events import PlatformEvent
from govoplan_core.core.modules import ModuleContext
from govoplan_core.core.search import (
SearchAuthorizationRequest,
SearchBackfillPage,
SearchBackfillRequest,
SearchDocument,
SearchIndexChange,
SearchResourceReference,
SearchResourceType,
)
from govoplan_wiki.backend.db.models import WikiPage, WikiPageRevision, WikiSpace
from govoplan_wiki.backend.service import can_read_page
PROVIDER_ID = "wiki.pages"
RESOURCE_TYPE = "wiki_page"
class WikiSearchSource:
def resource_types(self) -> Sequence[SearchResourceType]:
return (
SearchResourceType(
provider_id=PROVIDER_ID,
module_id="wiki",
resource_type=RESOURCE_TYPE,
label="Wiki pages",
requires_authorization_recheck=True,
),
)
def backfill(
self, session: object, *, request: SearchBackfillRequest
) -> SearchBackfillPage:
_assert_source(request.provider_id, request.resource_type)
db = _session(session)
query = select(WikiPage).where(
WikiPage.tenant_id == request.tenant_id,
WikiPage.published_revision.is_not(None),
WikiPage.state != "archived",
)
if request.cursor:
query = query.where(WikiPage.id > request.cursor)
rows = tuple(
db.scalars(query.order_by(WikiPage.id.asc()).limit(request.limit + 1))
)
has_more = len(rows) > request.limit
selected = rows[: request.limit]
spaces = _spaces(db, request.tenant_id, selected)
documents = tuple(
item
for row in selected
if (item := _document(db, row, spaces.get(row.space_id))) is not None
)
watermark = db.scalar(
select(func.max(WikiPage.updated_at)).where(
WikiPage.tenant_id == request.tenant_id,
WikiPage.published_revision.is_not(None),
WikiPage.state != "archived",
)
)
return SearchBackfillPage(
documents=documents,
next_cursor=selected[-1].id if has_more and selected else None,
complete=not has_more,
high_watermark=watermark.isoformat() if watermark else None,
)
def authorize(
self,
session: object,
principal: object,
*,
requests: Sequence[SearchAuthorizationRequest],
) -> Mapping[str, bool]:
db = _session(session)
tenant_id = str(getattr(principal, "tenant_id", "") or "")
decisions = {item.reference.key: False for item in requests}
for request in requests:
ref = request.reference
if (
ref.tenant_id != tenant_id
or ref.module_id != "wiki"
or ref.resource_type != RESOURCE_TYPE
):
continue
row = db.scalar(
select(WikiPage).where(
WikiPage.tenant_id == tenant_id, WikiPage.id == ref.resource_id
)
)
decisions[ref.key] = bool(
row is not None
and row.published_revision is not None
and row.state != "archived"
and can_read_page(db, principal, row)
)
return decisions
def index_changes_for_event(
self, session: object, *, event: PlatformEvent, delivery_key: str
) -> Sequence[SearchIndexChange]:
if (
event.module_id != "wiki"
or event.tenant is None
or event.resource is None
or event.resource.type != RESOURCE_TYPE
or event.resource.id is None
):
return ()
db = _session(session)
row = db.scalar(
select(WikiPage).where(
WikiPage.tenant_id == event.tenant.id, WikiPage.id == event.resource.id
)
)
space = (
db.scalar(
select(WikiSpace).where(
WikiSpace.tenant_id == event.tenant.id, WikiSpace.id == row.space_id
)
)
if row
else None
)
document = (
None
if row is None or row.published_revision is None or row.state == "archived"
else _document(db, row, space, change_cursor=event.event_id)
)
reference = SearchResourceReference(
tenant_id=event.tenant.id,
module_id="wiki",
resource_type=RESOURCE_TYPE,
resource_id=event.resource.id,
)
return (
SearchIndexChange(
change_id=f"{delivery_key}:{PROVIDER_ID}",
provider_id=PROVIDER_ID,
kind="upsert" if document else "delete",
reference=reference,
source_revision=document.source_revision
if document
else event.event_id,
cursor=event.event_id,
document=document,
occurred_at=event.occurred_at,
),
)
def create_wiki_search_source(_context: ModuleContext) -> WikiSearchSource:
return WikiSearchSource()
def _document(
db: Session,
row: WikiPage,
space: WikiSpace | None,
*,
change_cursor: str | None = None,
) -> SearchDocument | None:
if space is None or row.published_revision is None:
return None
revision = db.scalar(
select(WikiPageRevision).where(
WikiPageRevision.tenant_id == row.tenant_id,
WikiPageRevision.page_id == row.id,
WikiPageRevision.revision == row.published_revision,
)
)
if revision is None:
return None
snapshot = revision.snapshot
inherited = bool(snapshot.get("inherits_access"))
visibility = (
space.visibility
if inherited
else str(snapshot.get("effective_visibility") or space.visibility)
)
effective_tokens = (
space.acl_tokens if inherited else snapshot.get("effective_acl_tokens") or ()
)
acl_tokens = (
tuple(str(item) for item in effective_tokens)
if visibility == "restricted"
else ()
)
labels = tuple(str(item)[:200] for item in snapshot.get("labels") or ())
links = snapshot.get("links") or ()
link_labels = tuple(
str(item.get("label") or item.get("resource_id") or "")[:200]
for item in links
if isinstance(item, Mapping)
)
return SearchDocument(
tenant_id=row.tenant_id,
module_id="wiki",
provider_id=PROVIDER_ID,
resource_type=RESOURCE_TYPE,
resource_id=row.id,
title=str(snapshot.get("title") or row.title),
url=f"/wiki?pageId={quote(row.id, safe='')}",
summary=str(snapshot.get("summary") or "")[:2_000],
body=str(snapshot.get("body") or "")[:200_000],
keywords=tuple(dict.fromkeys((*labels, *link_labels, row.path)))[:200],
visibility=visibility,
acl_tokens=acl_tokens,
metadata={
"space_id": row.space_id,
"path": snapshot.get("path"),
"labels": list(labels),
"published_revision": row.published_revision,
"redirect_page_id": snapshot.get("redirect_page_id"),
},
source_revision=str(row.published_revision),
change_cursor=change_cursor,
source_updated_at=revision.recorded_at,
requires_authorization_recheck=True,
)
def _spaces(
db: Session, tenant_id: str, rows: Sequence[WikiPage]
) -> dict[str, WikiSpace]:
ids = tuple(dict.fromkeys(row.space_id for row in rows))
if not ids:
return {}
return {
row.id: row
for row in db.scalars(
select(WikiSpace).where(
WikiSpace.tenant_id == tenant_id, WikiSpace.id.in_(ids)
)
)
}
def _assert_source(provider_id: str, resource_type: str) -> None:
if provider_id != PROVIDER_ID or resource_type != RESOURCE_TYPE:
raise ValueError("Unsupported Wiki search source.")
def _session(value: object) -> Session:
if not isinstance(value, Session):
raise TypeError("Wiki search requires a SQLAlchemy session.")
return value
__all__ = [
"PROVIDER_ID",
"RESOURCE_TYPE",
"WikiSearchSource",
"create_wiki_search_source",
]
File diff suppressed because it is too large Load Diff