From 66c91351c9eb693ace606c9b69dd5cd804d7531b Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Sat, 22 Aug 2026 13:08:12 +0200 Subject: [PATCH] feat: implement governed wiki vertical slice --- README.md | 44 +- docs/WIKI_DOMAIN_BOUNDARY.md | 37 +- package.json | 34 +- pyproject.toml | 6 +- src/govoplan_wiki/backend/db/__init__.py | 17 + src/govoplan_wiki/backend/db/models.py | 194 ++ src/govoplan_wiki/backend/dsar_provider.py | 352 ++++ src/govoplan_wiki/backend/manifest.py | 519 +++++- .../backend/migrations/__init__.py | 1 + .../backend/migrations/versions/__init__.py | 1 + .../a7c2e9f4b1d6_v0120_wiki_vertical_slice.py | 261 +++ src/govoplan_wiki/backend/router.py | 361 ++++ src/govoplan_wiki/backend/schemas.py | 74 + src/govoplan_wiki/backend/search_source.py | 264 +++ src/govoplan_wiki/backend/service.py | 1564 +++++++++++++++++ tests/test_manifest.py | 44 +- tests/test_wiki_service.py | 390 ++++ webui/package.json | 31 + webui/scripts/test-interface-pattern.mjs | 25 + webui/src/api/wiki.ts | 147 ++ webui/src/features/wiki/WikiPage.tsx | 282 +++ webui/src/index.ts | 2 + webui/src/module.ts | 30 + webui/src/styles/wiki.css | 338 ++++ 24 files changed, 4934 insertions(+), 84 deletions(-) create mode 100644 src/govoplan_wiki/backend/db/__init__.py create mode 100644 src/govoplan_wiki/backend/db/models.py create mode 100644 src/govoplan_wiki/backend/dsar_provider.py create mode 100644 src/govoplan_wiki/backend/migrations/__init__.py create mode 100644 src/govoplan_wiki/backend/migrations/versions/__init__.py create mode 100644 src/govoplan_wiki/backend/migrations/versions/a7c2e9f4b1d6_v0120_wiki_vertical_slice.py create mode 100644 src/govoplan_wiki/backend/router.py create mode 100644 src/govoplan_wiki/backend/schemas.py create mode 100644 src/govoplan_wiki/backend/search_source.py create mode 100644 src/govoplan_wiki/backend/service.py create mode 100644 tests/test_wiki_service.py create mode 100644 webui/package.json create mode 100644 webui/scripts/test-interface-pattern.mjs create mode 100644 webui/src/api/wiki.ts create mode 100644 webui/src/features/wiki/WikiPage.tsx create mode 100644 webui/src/index.ts create mode 100644 webui/src/module.ts create mode 100644 webui/src/styles/wiki.css diff --git a/README.md b/README.md index 529f374..c645095 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,14 @@ **Repository type:** module (domain). -`govoplan-wiki` owns collaborative, revisioned knowledge pages and spaces. -It is the native GovOPlaN alternative and integration target for tools such as -MediaWiki and BlueSpice. +`govoplan-wiki` owns governed collaborative knowledge spaces and hierarchical, +revisioned pages. It is the native GovOPlaN alternative and integration target +for tools such as MediaWiki and BlueSpice. -The runtime module ID is `wiki`. This initial scaffold registers the domain -boundary, permissions, roles, documentation, and module entry point. Runtime -storage and WebUI routes will follow as bounded implementation slices. +The runtime module ID is `wiki`. The v0.1.20 vertical slice provides tenant-safe +spaces, hierarchical drafts, immutable revisions, optimistic concurrency, +publication and redirects, labels, typed links, append-only comments, access +inheritance, ACL-aware Search, DSAR participation export, and a focused WebUI. ## Boundary @@ -19,6 +20,34 @@ publishing state. It does not own binary file storage, formal records, product documentation, workflow execution, or general-purpose collaborative document editing. +## Runtime Surface + +- `/api/v1/wiki/spaces` for governed spaces, access inheritance, publishing + mode, optimistic updates, and archival +- `/api/v1/wiki/pages` for page hierarchy, drafts, labels, references, redirects, + publication, and archival +- immutable page revision history and bounded unified-diff comparison +- append-only comments bound to the page revision under discussion +- `wiki.registry` for stable, authorization-checked cross-module references +- `wiki.pages` Search projection of the last published revision only +- `privacy.dsar.wiki` for exact-identifier, bounded participation export and + manual-review erasure planning +- `/wiki` tree/detail workspace built from shared Core layout, action-bar, + dialog, status, state, and selection primitives + +Editing a published page creates a draft while the last published revision +remains visible to readers and Search. This prevents ordinary authoring from +silently withdrawing approved knowledge. + +## Optional Integrations + +Files and DMS own referenced binary content. Search discovers published pages. +Records can preserve a published revision as evidence. Projects, Cases, Tasks, +templates, and workflows can be linked without importing their lifecycles. +Connectors owns external Wiki discovery, credentials, transport, +reconciliation, and migration execution. Each absence leaves the native Wiki +usable and is documented as an explicit operational consequence. + See [docs/WIKI_DOMAIN_BOUNDARY.md](docs/WIKI_DOMAIN_BOUNDARY.md). ## Development @@ -26,5 +55,6 @@ See [docs/WIKI_DOMAIN_BOUNDARY.md](docs/WIKI_DOMAIN_BOUNDARY.md). ```bash cd /mnt/DATA/git/govoplan-wiki PYTHONPATH=src:/mnt/DATA/git/govoplan-core/src \ - /mnt/DATA/git/govoplan/.venv/bin/python -m unittest discover -s tests + /mnt/DATA/git/govoplan/.venv/bin/python -m pytest -q +cd webui && node scripts/test-interface-pattern.mjs ``` diff --git a/docs/WIKI_DOMAIN_BOUNDARY.md b/docs/WIKI_DOMAIN_BOUNDARY.md index 5c16d02..93bc937 100644 --- a/docs/WIKI_DOMAIN_BOUNDARY.md +++ b/docs/WIKI_DOMAIN_BOUNDARY.md @@ -24,8 +24,37 @@ revision as evidence. Search indexes only revisions the principal may discover. Projects, Tickets, and Cases may link pages as living knowledge. Connectors owns MediaWiki/BlueSpice discovery, synchronization, and migration. -## First Slice +## Governed Vertical Slice -Implement spaces, revisioned pages, links, access checks, and -permission-aware search publication before adding rich editing or external -synchronization. +The native slice stores a current editorial projection plus an immutable page +revision for every creation, edit, publication, redirect, or archive action. +Writes require the expected revision and an idempotency key. A published page +can gain a later draft without withdrawing its last published revision from +readers or Search. + +Visibility is either tenant-wide or restricted to exact account, identity, +membership, group, role, or function-assignment tokens. Pages inherit their +space access by default and may define an explicit override. Permissions remain +distinct for reading, editing, commenting, publishing, and space +administration, and are checked again at each service action. + +Typed links retain the owner module and stable resource identity. Attachment +links can target Files or DMS only; no binary bytes are copied into Wiki. +External provenance can be recorded without claiming transport authority. + +Search receives only the last published revision, effective visibility, and +ACL tokens, and rechecks every result against current Wiki state. Drafts and +archives never become searchable documents. DSAR discovery matches exact +subject identifiers in ACL membership, actor attribution, and the subject's +own comments. Minimization remains a manual knowledge-owner and retention +decision. + +## Current Limits + +- external MediaWiki or BlueSpice discovery, credentials, migration execution, + synchronization, and reconciliation require a Connectors provider +- the initial editor stores governed plain text; rich-text editing and presence + awareness can be layered over the same revision contract later +- a page with active child pages must have those children moved or archived + before its path changes or it is archived, preventing silent descendant path + drift diff --git a/package.json b/package.json index 76fc668..5e8370e 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,34 @@ { - "name": "@govoplan/wiki", - "version": "0.1.19", + "name": "@govoplan/wiki-webui", + "version": "0.1.20", "private": true, - "description": "GovOPlaN collaborative Wiki module.", + "description": "GovOPlaN governed collaborative Wiki module.", "type": "module", - "peerDependencies": {} + "main": "webui/src/index.ts", + "module": "webui/src/index.ts", + "types": "webui/src/index.ts", + "exports": { + ".": { + "types": "./webui/src/index.ts", + "import": "./webui/src/index.ts" + }, + "./styles/wiki.css": "./webui/src/styles/wiki.css" + }, + "files": [ + "webui/src", + "README.md", + "LICENSE" + ], + "peerDependencies": { + "@govoplan/core-webui": "^0.1.31", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } } diff --git a/pyproject.toml b/pyproject.toml index 7f89f5f..5c1a689 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,14 +4,14 @@ build-backend = "setuptools.build_meta" [project] name = "govoplan-wiki" -version = "0.1.19" -description = "GovOPlaN collaborative Wiki module." +version = "0.1.20" +description = "GovOPlaN governed collaborative Wiki module." readme = "README.md" requires-python = ">=3.12" license = { file = "LICENSE" } authors = [{ name = "GovOPlaN" }] dependencies = [ - "govoplan-core>=0.1.18", + "govoplan-core>=0.1.31", "govoplan-access>=0.1.18", ] diff --git a/src/govoplan_wiki/backend/db/__init__.py b/src/govoplan_wiki/backend/db/__init__.py new file mode 100644 index 0000000..275e93e --- /dev/null +++ b/src/govoplan_wiki/backend/db/__init__.py @@ -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", +] diff --git a/src/govoplan_wiki/backend/db/models.py b/src/govoplan_wiki/backend/db/models.py new file mode 100644 index 0000000..8fe76ff --- /dev/null +++ b/src/govoplan_wiki/backend/db/models.py @@ -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", +] diff --git a/src/govoplan_wiki/backend/dsar_provider.py b/src/govoplan_wiki/backend/dsar_provider.py new file mode 100644 index 0000000..e1ebb43 --- /dev/null +++ b/src/govoplan_wiki/backend/dsar_provider.py @@ -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"] diff --git a/src/govoplan_wiki/backend/manifest.py b/src/govoplan_wiki/backend/manifest.py index 14245a3..523f812 100644 --- a/src/govoplan_wiki/backend/manifest.py +++ b/src/govoplan_wiki/backend/manifest.py @@ -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, ) diff --git a/src/govoplan_wiki/backend/migrations/__init__.py b/src/govoplan_wiki/backend/migrations/__init__.py new file mode 100644 index 0000000..7cbe6e4 --- /dev/null +++ b/src/govoplan_wiki/backend/migrations/__init__.py @@ -0,0 +1 @@ +"""Wiki Alembic migrations.""" diff --git a/src/govoplan_wiki/backend/migrations/versions/__init__.py b/src/govoplan_wiki/backend/migrations/versions/__init__.py new file mode 100644 index 0000000..e2bd13d --- /dev/null +++ b/src/govoplan_wiki/backend/migrations/versions/__init__.py @@ -0,0 +1 @@ +"""Wiki migration revisions.""" diff --git a/src/govoplan_wiki/backend/migrations/versions/a7c2e9f4b1d6_v0120_wiki_vertical_slice.py b/src/govoplan_wiki/backend/migrations/versions/a7c2e9f4b1d6_v0120_wiki_vertical_slice.py new file mode 100644 index 0000000..cf7fb9d --- /dev/null +++ b/src/govoplan_wiki/backend/migrations/versions/a7c2e9f4b1d6_v0120_wiki_vertical_slice.py @@ -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") diff --git a/src/govoplan_wiki/backend/router.py b/src/govoplan_wiki/backend/router.py new file mode 100644 index 0000000..05aad07 --- /dev/null +++ b/src/govoplan_wiki/backend/router.py @@ -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"] diff --git a/src/govoplan_wiki/backend/schemas.py b/src/govoplan_wiki/backend/schemas.py new file mode 100644 index 0000000..6ca04a4 --- /dev/null +++ b/src/govoplan_wiki/backend/schemas.py @@ -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", +] diff --git a/src/govoplan_wiki/backend/search_source.py b/src/govoplan_wiki/backend/search_source.py new file mode 100644 index 0000000..5f38198 --- /dev/null +++ b/src/govoplan_wiki/backend/search_source.py @@ -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", +] diff --git a/src/govoplan_wiki/backend/service.py b/src/govoplan_wiki/backend/service.py new file mode 100644 index 0000000..32e8f95 --- /dev/null +++ b/src/govoplan_wiki/backend/service.py @@ -0,0 +1,1564 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime +import difflib +import hashlib +import json +import re +from typing import Any + +from sqlalchemy.orm import Session + +from govoplan_core.core.events import ( + EventActorRef, + EventObjectRef, + EventTenantRef, + PlatformEvent, + emit_platform_event, +) +from govoplan_core.security.module_permissions import scopes_grant_compatible +from govoplan_wiki.backend.db.models import ( + WikiComment, + WikiPage, + WikiPageRevision, + WikiSpace, + WikiSpaceHistory, +) + + +CAPABILITY_WIKI_REGISTRY = "wiki.registry" +READ_SCOPE = "wiki:page:read" +WRITE_SCOPE = "wiki:page:write" +COMMENT_SCOPE = "wiki:page:comment" +PUBLISH_SCOPE = "wiki:page:publish" +ADMIN_SCOPE = "wiki:space:admin" +_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_ACL_PREFIXES = frozenset( + {"account", "identity", "membership", "group", "role", "function_assignment"} +) +_VISIBILITIES = frozenset({"tenant", "restricted"}) +_PUBLISH_MODES = frozenset({"publishers", "editors"}) + + +class WikiStoreError(ValueError): + pass + + +class WikiNotFoundError(LookupError): + pass + + +class WikiConflictError(WikiStoreError): + pass + + +def create_space( + session: Session, + principal: object, + *, + space_id: str, + space_key: str, + title: str, + description: str, + visibility: str, + acl_tokens: Sequence[str], + publish_mode: str, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_scope(principal, ADMIN_SCOPE) + tenant_id = _tenant(principal) + payload = { + "space_id": _bounded(space_id, "Space id", 255), + "space_key": _slug(space_key, "Space key", 120), + "title": _bounded(title, "Space title", 500), + "description": _optional(description, 20_000), + "visibility": _visibility(visibility), + "acl_tokens": _acl(acl_tokens), + "publish_mode": _choice(publish_mode, _PUBLISH_MODES, "publish mode"), + "recorded_at": _aware(recorded_at, "Recorded at"), + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + _restricted_requires_acl(payload["visibility"], payload["acl_tokens"]) + key = _bounded(idempotency_key, "Idempotency key", 255) + request_hash = _sha(payload) + replay = _space_replay(session, tenant_id, key, request_hash) + if replay is not None: + return replay + if session.get(WikiSpace, payload["space_id"]) is not None: + raise WikiConflictError("A Wiki space with this identifier already exists.") + actor = _actor(principal) + row = WikiSpace( + id=payload["space_id"], + tenant_id=tenant_id, + space_key=payload["space_key"], + revision=1, + title=payload["title"], + description=payload["description"], + visibility=payload["visibility"], + acl_tokens=payload["acl_tokens"], + publish_mode=payload["publish_mode"], + created_by=actor, + updated_by=actor, + ) + session.add(row) + session.flush() + snapshot = _space_payload(row) + _append_space_history( + session, + row, + "created", + payload["recorded_at"], + actor, + payload["change_reason"], + key, + request_hash, + snapshot, + ) + _emit( + session, + registry, + row.tenant_id, + "wiki.space.created", + "wiki_space", + row.id, + row.title, + row.revision, + actor, + payload["recorded_at"], + ) + return snapshot + + +def update_space( + session: Session, + principal: object, + *, + space_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_scope(principal, ADMIN_SCOPE) + allowed = {"title", "description", "visibility", "acl_tokens", "publish_mode"} + unexpected = set(changes) - allowed + if unexpected: + raise WikiStoreError( + "Unsupported Wiki space changes: " + ", ".join(sorted(unexpected)) + ) + row = _space(session, principal, space_id, lock=True) + normalized: dict[str, object] = {} + if "title" in changes: + normalized["title"] = _bounded(changes["title"], "Space title", 500) + if "description" in changes: + normalized["description"] = _optional(changes["description"], 20_000) + if "visibility" in changes: + normalized["visibility"] = _visibility(changes["visibility"]) + if "acl_tokens" in changes: + normalized["acl_tokens"] = _acl(changes["acl_tokens"]) + if "publish_mode" in changes: + normalized["publish_mode"] = _choice( + changes["publish_mode"], _PUBLISH_MODES, "publish mode" + ) + effective_visibility = str(normalized.get("visibility", row.visibility)) + effective_acl = normalized.get("acl_tokens", row.acl_tokens) + _restricted_requires_acl(effective_visibility, effective_acl) + payload = { + "space_id": space_id, + "expected_revision": expected_revision, + "changes": normalized, + "recorded_at": _aware(recorded_at, "Recorded at"), + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + return _mutate_space( + session, + principal, + row, + expected_revision, + payload, + "updated", + idempotency_key, + registry, + ) + + +def archive_space( + session: Session, + principal: object, + *, + space_id: str, + expected_revision: int, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_scope(principal, ADMIN_SCOPE) + row = _space(session, principal, space_id, lock=True) + payload = { + "space_id": space_id, + "expected_revision": expected_revision, + "changes": {"archived_at": _aware(recorded_at, "Recorded at")}, + "recorded_at": recorded_at, + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + return _mutate_space( + session, + principal, + row, + expected_revision, + payload, + "archived", + idempotency_key, + registry, + ) + + +def list_spaces( + session: Session, principal: object, *, include_archived: bool = False +) -> tuple[dict[str, object], ...]: + _require_scope(principal, READ_SCOPE) + query = session.query(WikiSpace).filter(WikiSpace.tenant_id == _tenant(principal)) + if not include_archived: + query = query.filter(WikiSpace.archived_at.is_(None)) + return tuple( + _space_payload(row) + for row in query.order_by(WikiSpace.title.asc()).all() + if can_access_space(principal, row) + ) + + +def get_space( + session: Session, principal: object, *, space_id: str +) -> dict[str, object] | None: + row = ( + session.query(WikiSpace) + .filter(WikiSpace.tenant_id == _tenant(principal), WikiSpace.id == space_id) + .one_or_none() + ) + return ( + _space_payload(row) + if row is not None and can_access_space(principal, row) + else None + ) + + +def create_page( + session: Session, + principal: object, + *, + page_id: str, + space_id: str, + parent_page_id: str | None, + slug: str, + title: str, + body: str, + summary: str, + inherits_access: bool, + visibility: str, + acl_tokens: Sequence[str], + labels: Sequence[str], + links: Sequence[Mapping[str, object]], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_scope(principal, WRITE_SCOPE) + tenant_id = _tenant(principal) + space = _space(session, principal, space_id) + _require_space_write(principal, space) + if space.archived_at is not None: + raise WikiConflictError("Pages cannot be added to an archived Wiki space.") + parent = _parent(session, principal, space, parent_page_id) + clean_slug = _slug(slug, "Page slug", 160) + payload: dict[str, object] = { + "page_id": _bounded(page_id, "Page id", 255), + "space_id": space_id, + "parent_page_id": parent.id if parent else None, + "slug": clean_slug, + "path": f"{parent.path}/{clean_slug}" if parent else clean_slug, + "title": _bounded(title, "Page title", 500), + "body": _optional(body, 500_000), + "summary": _optional(summary, 10_000), + "inherits_access": bool(inherits_access), + "visibility": "inherit" if inherits_access else _visibility(visibility), + "acl_tokens": [] if inherits_access else _acl(acl_tokens), + "labels": _labels(labels), + "links": _links(links), + "recorded_at": _aware(recorded_at, "Recorded at"), + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + if not inherits_access: + _restricted_requires_acl(payload["visibility"], payload["acl_tokens"]) + key = _bounded(idempotency_key, "Idempotency key", 255) + request_hash = _sha(payload) + replay = _page_replay(session, tenant_id, key, request_hash) + if replay is not None: + return replay + if session.get(WikiPage, payload["page_id"]) is not None: + raise WikiConflictError("A Wiki page with this identifier already exists.") + actor = _actor(principal) + row = WikiPage( + id=payload["page_id"], + tenant_id=tenant_id, + space_id=space.id, + parent_page_id=payload["parent_page_id"], + slug=payload["slug"], + path=payload["path"], + revision=1, + published_revision=None, + state="draft", + title=payload["title"], + body=payload["body"], + summary=payload["summary"], + visibility=payload["visibility"], + inherits_access=payload["inherits_access"], + acl_tokens=payload["acl_tokens"], + labels=payload["labels"], + links=payload["links"], + redirect_page_id=None, + search_text=_search_text(payload), + created_by=actor, + updated_by=actor, + ) + session.add(row) + session.flush() + snapshot = _page_payload(row, space=space) + _append_page_revision( + session, + row, + "created", + payload["recorded_at"], + actor, + payload["change_reason"], + key, + request_hash, + snapshot, + ) + _emit( + session, + registry, + tenant_id, + "wiki.page.created", + "wiki_page", + row.id, + row.title, + row.revision, + actor, + payload["recorded_at"], + ) + return snapshot + + +def update_page( + session: Session, + principal: object, + *, + page_id: str, + expected_revision: int, + changes: Mapping[str, object], + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_scope(principal, WRITE_SCOPE) + row, space = _page_and_space(session, principal, page_id, lock=True, govern=True) + if space.archived_at is not None or row.state == "archived": + raise WikiConflictError("Archived Wiki content cannot be revised.") + allowed = { + "title", + "body", + "summary", + "slug", + "parent_page_id", + "inherits_access", + "visibility", + "acl_tokens", + "labels", + "links", + "redirect_page_id", + } + unexpected = set(changes) - allowed + if unexpected: + raise WikiStoreError( + "Unsupported Wiki page changes: " + ", ".join(sorted(unexpected)) + ) + normalized: dict[str, object] = {} + for name, maximum in (("title", 500), ("body", 500_000), ("summary", 10_000)): + if name in changes: + normalized[name] = ( + _bounded(changes[name], "Page title", maximum) + if name == "title" + else _optional(changes[name], maximum) + ) + if "slug" in changes: + normalized["slug"] = _slug(changes["slug"], "Page slug", 160) + if "labels" in changes: + normalized["labels"] = _labels(changes["labels"]) + if "links" in changes: + normalized["links"] = _links(changes["links"]) + if "inherits_access" in changes: + normalized["inherits_access"] = bool(changes["inherits_access"]) + inherits = bool(normalized.get("inherits_access", row.inherits_access)) + if inherits: + normalized["visibility"] = "inherit" + normalized["acl_tokens"] = [] + else: + if "visibility" in changes: + normalized["visibility"] = _visibility(changes["visibility"]) + elif row.visibility == "inherit": + normalized["visibility"] = "tenant" + if "acl_tokens" in changes: + normalized["acl_tokens"] = _acl(changes["acl_tokens"]) + effective_visibility = str(normalized.get("visibility", row.visibility)) + effective_acl = normalized.get("acl_tokens", row.acl_tokens) + _restricted_requires_acl(effective_visibility, effective_acl) + new_parent_id = changes.get("parent_page_id", row.parent_page_id) + parent = _parent( + session, principal, space, str(new_parent_id) if new_parent_id else None + ) + if parent is not None and parent.id == row.id: + raise WikiStoreError("A page cannot be its own parent.") + if ("parent_page_id" in changes or "slug" in changes) and session.query( + WikiPage.id + ).filter( + WikiPage.tenant_id == row.tenant_id, WikiPage.parent_page_id == row.id + ).first(): + raise WikiConflictError( + "Move or rename child pages before changing this page path." + ) + normalized["parent_page_id"] = parent.id if parent else None + next_slug = str(normalized.get("slug", row.slug)) + normalized["path"] = f"{parent.path}/{next_slug}" if parent else next_slug + if "redirect_page_id" in changes: + target = changes["redirect_page_id"] + if target is not None: + target_row = ( + session.query(WikiPage) + .filter( + WikiPage.tenant_id == row.tenant_id, + WikiPage.space_id == row.space_id, + WikiPage.id == str(target), + ) + .one_or_none() + ) + if target_row is None or target_row.id == row.id: + raise WikiStoreError( + "Redirect target must be another page in the same space." + ) + normalized["redirect_page_id"] = target_row.id + else: + normalized["redirect_page_id"] = None + # Every content update is a draft. The last published revision remains visible + # until a separate publish action appends the next immutable revision. + normalized["state"] = "draft" + payload = { + "page_id": page_id, + "expected_revision": expected_revision, + "changes": normalized, + "recorded_at": _aware(recorded_at, "Recorded at"), + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + return _mutate_page( + session, + principal, + row, + space, + expected_revision, + payload, + "revised", + idempotency_key, + registry, + ) + + +def publish_page( + session: Session, + principal: object, + *, + page_id: str, + expected_revision: int, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + row, space = _page_and_space(session, principal, page_id, lock=True, govern=True) + if space.publish_mode == "editors": + _require_any_scope(principal, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE) + else: + _require_any_scope(principal, PUBLISH_SCOPE, ADMIN_SCOPE) + if row.state == "archived" or space.archived_at is not None: + raise WikiConflictError("Archived Wiki content cannot be published.") + next_revision = expected_revision + 1 + payload = { + "page_id": page_id, + "expected_revision": expected_revision, + "changes": { + "state": "redirected" if row.redirect_page_id else "published", + "published_revision": next_revision, + "published_at": _aware(recorded_at, "Recorded at"), + "archived_at": None, + }, + "recorded_at": recorded_at, + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + return _mutate_page( + session, + principal, + row, + space, + expected_revision, + payload, + "published", + idempotency_key, + registry, + ) + + +def archive_page( + session: Session, + principal: object, + *, + page_id: str, + expected_revision: int, + recorded_at: datetime, + change_reason: str, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_any_scope(principal, PUBLISH_SCOPE, ADMIN_SCOPE) + row, space = _page_and_space(session, principal, page_id, lock=True, govern=True) + if ( + session.query(WikiPage.id) + .filter( + WikiPage.tenant_id == row.tenant_id, + WikiPage.parent_page_id == row.id, + WikiPage.state != "archived", + ) + .first() + ): + raise WikiConflictError("Archive or move active child pages first.") + payload = { + "page_id": page_id, + "expected_revision": expected_revision, + "changes": { + "state": "archived", + "archived_at": _aware(recorded_at, "Recorded at"), + }, + "recorded_at": recorded_at, + "change_reason": _bounded(change_reason, "Change reason", 1_000), + } + return _mutate_page( + session, + principal, + row, + space, + expected_revision, + payload, + "archived", + idempotency_key, + registry, + ) + + +def get_page( + session: Session, principal: object, *, page_id: str +) -> dict[str, object] | None: + row = ( + session.query(WikiPage) + .filter(WikiPage.tenant_id == _tenant(principal), WikiPage.id == page_id) + .one_or_none() + ) + if row is None: + return None + space = ( + session.query(WikiSpace) + .filter(WikiSpace.tenant_id == row.tenant_id, WikiSpace.id == row.space_id) + .one() + ) + if not can_read_page(session, principal, row, space=space): + return None + if not _editor(principal) and row.published_revision != row.revision: + revision = _revision(session, row, row.published_revision) + return _published_payload(revision, space) if revision is not None else None + return _page_payload(row, space=space) + + +def list_pages( + session: Session, + principal: object, + *, + space_id: str | None = None, + parent_page_id: str | None = None, + states: Sequence[str] = (), + query: str = "", + include_archived: bool = False, + offset: int = 0, + limit: int = 100, +) -> tuple[tuple[dict[str, object], ...], int]: + _require_scope(principal, READ_SCOPE) + if offset < 0 or not 1 <= limit <= 200: + raise WikiStoreError( + "Page list offset must be non-negative and limit between 1 and 200." + ) + statement = session.query(WikiPage).filter(WikiPage.tenant_id == _tenant(principal)) + if space_id: + statement = statement.filter(WikiPage.space_id == space_id) + if parent_page_id is not None: + statement = statement.filter(WikiPage.parent_page_id == parent_page_id) + if states: + statement = statement.filter(WikiPage.state.in_(tuple(states))) + if not include_archived: + statement = statement.filter(WikiPage.state != "archived") + clean_query = str(query or "").strip().casefold() + if clean_query: + statement = statement.filter(WikiPage.search_text.contains(clean_query)) + spaces = { + row.id: row + for row in session.query(WikiSpace) + .filter(WikiSpace.tenant_id == _tenant(principal)) + .all() + } + accessible = [] + for row in statement.order_by(WikiPage.path.asc()).all(): + space = spaces.get(row.space_id) + if space is None or not can_read_page(session, principal, row, space=space): + continue + if not _editor(principal) and row.published_revision != row.revision: + published = _revision(session, row, row.published_revision) + if published is not None: + accessible.append(_published_payload(published, space)) + else: + accessible.append(_page_payload(row, space=space)) + return tuple(accessible[offset : offset + limit]), len(accessible) + + +def page_revisions( + session: Session, principal: object, *, page_id: str +) -> tuple[dict[str, object], ...]: + row, _space_row = _page_and_space(session, principal, page_id) + if not _editor(principal): + raise PermissionError("Revision history requires Wiki edit access.") + rows = ( + session.query(WikiPageRevision) + .filter( + WikiPageRevision.tenant_id == row.tenant_id, + WikiPageRevision.page_id == row.id, + ) + .order_by(WikiPageRevision.revision.desc()) + .limit(500) + .all() + ) + return tuple( + { + "revision": item.revision, + "event_type": item.event_type, + "recorded_at": _iso(item.recorded_at), + "actor_id": item.actor_id, + "change_reason": item.change_reason, + "snapshot": dict(item.snapshot), + } + for item in rows + ) + + +def compare_revisions( + session: Session, + principal: object, + *, + page_id: str, + from_revision: int, + to_revision: int, +) -> dict[str, object]: + row, _space_row = _page_and_space(session, principal, page_id) + if not _editor(principal): + raise PermissionError("Revision comparison requires Wiki edit access.") + before = _revision(session, row, from_revision) + after = _revision(session, row, to_revision) + if before is None or after is None: + raise WikiNotFoundError("Wiki page revision not found.") + before_body = str(before.snapshot.get("body") or "") + after_body = str(after.snapshot.get("body") or "") + lines = list( + difflib.unified_diff( + before_body.splitlines(), + after_body.splitlines(), + fromfile=f"revision-{from_revision}", + tofile=f"revision-{to_revision}", + lineterm="", + ) + ) + joined = "\n".join(lines) + truncated = len(joined) > 200_000 + return { + "page_id": page_id, + "from_revision": from_revision, + "to_revision": to_revision, + "diff": joined[:200_000], + "truncated": truncated, + } + + +def add_comment( + session: Session, + principal: object, + *, + page_id: str, + expected_revision: int, + comment_id: str, + body: str, + recorded_at: datetime, + idempotency_key: str, + registry: object | None = None, +) -> dict[str, object]: + _require_any_scope(principal, COMMENT_SCOPE, WRITE_SCOPE, ADMIN_SCOPE) + row, _space_row = _page_and_space(session, principal, page_id, govern=True) + payload = { + "page_id": page_id, + "expected_revision": expected_revision, + "comment_id": _bounded(comment_id, "Comment id", 255), + "body": _bounded(body, "Comment body", 20_000), + "recorded_at": _aware(recorded_at, "Recorded at"), + } + key = _bounded(idempotency_key, "Idempotency key", 255) + request_hash = _sha(payload) + replay = ( + session.query(WikiComment) + .filter( + WikiComment.tenant_id == row.tenant_id, WikiComment.idempotency_key == key + ) + .one_or_none() + ) + if replay is not None: + if replay.request_sha256 != request_hash: + raise WikiConflictError( + "Idempotency key was already used with a different comment request." + ) + return _comment_payload(replay) + if row.revision != expected_revision: + raise WikiConflictError( + f"Wiki page revision conflict: expected {expected_revision}, current {row.revision}." + ) + comment = WikiComment( + tenant_id=row.tenant_id, + page_id=row.id, + comment_id=payload["comment_id"], + page_revision=row.revision, + body=payload["body"], + created_by=_actor(principal), + recorded_at=payload["recorded_at"], + idempotency_key=key, + request_sha256=request_hash, + ) + session.add(comment) + session.flush() + _emit( + session, + registry, + row.tenant_id, + "wiki.page.commented", + "wiki_page", + row.id, + row.title, + row.revision, + _actor(principal), + payload["recorded_at"], + ) + return _comment_payload(comment) + + +def list_comments( + session: Session, principal: object, *, page_id: str +) -> tuple[dict[str, object], ...]: + row, _space_row = _page_and_space(session, principal, page_id) + return tuple( + _comment_payload(item) + for item in session.query(WikiComment) + .filter(WikiComment.tenant_id == row.tenant_id, WikiComment.page_id == row.id) + .order_by(WikiComment.recorded_at.asc()) + .limit(500) + .all() + ) + + +def can_access_space(principal: object, row: WikiSpace) -> bool: + return ( + row.tenant_id == _tenant(principal) + and _has_scope(principal, READ_SCOPE) + and ( + _has_scope(principal, ADMIN_SCOPE) + or row.visibility == "tenant" + or bool(set(row.acl_tokens or ()) & set(_principal_tokens(principal))) + ) + ) + + +def can_read_page( + session: Session, + principal: object, + row: WikiPage, + *, + space: WikiSpace | None = None, +) -> bool: + if row.tenant_id != _tenant(principal) or not _has_scope(principal, READ_SCOPE): + return False + space = ( + space + or session.query(WikiSpace) + .filter(WikiSpace.tenant_id == row.tenant_id, WikiSpace.id == row.space_id) + .one_or_none() + ) + if space is None or not _access( + row.inherits_access, row.visibility, row.acl_tokens, space, principal + ): + return False + if _editor(principal): + return True + return row.published_revision is not None and row.state != "archived" + + +class SqlWikiRegistry: + def resolve_page( + self, session: object, principal: object, *, page_id: str + ) -> dict[str, object] | None: + if not isinstance(session, Session): + raise TypeError("Wiki registry requires a SQLAlchemy session.") + page = get_page(session, principal, page_id=page_id) + if page is None: + return None + return { + "page_id": page["page_id"], + "space_id": page["space_id"], + "title": page["title"], + "state": page["state"], + "revision": page["revision"], + "url": f"/wiki?pageId={page['page_id']}", + } + + +def integration_availability(registry: object | None) -> dict[str, object]: + active = _active_modules(registry) + return { + "files": "files" in active, + "dms": "dms" in active, + "search": "search" in active, + "templates": "templates" in active, + "workflow_engine": "workflow_engine" in active, + "projects": "projects" in active, + "cases": "cases" in active, + "notifications": "notifications" in active, + "consequences": { + "files": "Attachments remain governed references; Wiki never stores binary content.", + "search": "Published pages remain browsable in Wiki but are not projected into global Search.", + "connectors": "External Wiki transport and migration are unavailable without a connector provider.", + }, + } + + +def _mutate_space( + session: Session, + principal: object, + row: WikiSpace, + expected_revision: int, + payload: dict[str, object], + event_type: str, + idempotency_key: str, + registry: object | None, +) -> dict[str, object]: + key = _bounded(idempotency_key, "Idempotency key", 255) + request_hash = _sha(payload) + replay = _space_replay(session, row.tenant_id, key, request_hash) + if replay is not None: + return replay + if event_type == "archived" and row.archived_at is not None: + raise WikiConflictError("Wiki space is already archived.") + if row.revision != expected_revision: + raise WikiConflictError( + f"Wiki space revision conflict: expected {expected_revision}, current {row.revision}." + ) + for name, value in dict(payload["changes"]).items(): + setattr(row, name, value) + row.revision += 1 + row.updated_by = _actor(principal) + session.flush() + snapshot = _space_payload(row) + _append_space_history( + session, + row, + event_type, + payload["recorded_at"], + row.updated_by, + payload["change_reason"], + key, + request_hash, + snapshot, + ) + _emit( + session, + registry, + row.tenant_id, + f"wiki.space.{event_type}", + "wiki_space", + row.id, + row.title, + row.revision, + row.updated_by, + payload["recorded_at"], + ) + return snapshot + + +def _mutate_page( + session: Session, + principal: object, + row: WikiPage, + space: WikiSpace, + expected_revision: int, + payload: dict[str, object], + event_type: str, + idempotency_key: str, + registry: object | None, +) -> dict[str, object]: + key = _bounded(idempotency_key, "Idempotency key", 255) + request_hash = _sha(payload) + replay = _page_replay(session, row.tenant_id, key, request_hash) + if replay is not None: + return replay + if event_type == "archived" and row.state == "archived": + raise WikiConflictError("Wiki page is already archived.") + if event_type == "published" and row.state in {"published", "redirected"}: + raise WikiConflictError("Wiki page revision is already published.") + if row.revision != expected_revision: + raise WikiConflictError( + f"Wiki page revision conflict: expected {expected_revision}, current {row.revision}." + ) + for name, value in dict(payload["changes"]).items(): + setattr(row, name, value) + row.revision += 1 + row.updated_by = _actor(principal) + row.search_text = _search_text( + { + "title": row.title, + "body": row.body, + "summary": row.summary, + "labels": row.labels, + "links": row.links, + } + ) + session.flush() + snapshot = _page_payload(row, space=space) + _append_page_revision( + session, + row, + event_type, + payload["recorded_at"], + row.updated_by, + payload["change_reason"], + key, + request_hash, + snapshot, + ) + _emit( + session, + registry, + row.tenant_id, + f"wiki.page.{event_type}", + "wiki_page", + row.id, + row.title, + row.revision, + row.updated_by, + payload["recorded_at"], + ) + return snapshot + + +def _space( + session: Session, principal: object, space_id: str, *, lock: bool = False +) -> WikiSpace: + query = session.query(WikiSpace).filter( + WikiSpace.tenant_id == _tenant(principal), WikiSpace.id == space_id + ) + row = query.with_for_update().one_or_none() if lock else query.one_or_none() + if row is None: + raise WikiNotFoundError("Wiki space not found.") + return row + + +def _page_and_space( + session: Session, + principal: object, + page_id: str, + *, + lock: bool = False, + edit: bool = False, + govern: bool = False, +) -> tuple[WikiPage, WikiSpace]: + query = session.query(WikiPage).filter( + WikiPage.tenant_id == _tenant(principal), WikiPage.id == page_id + ) + row = query.with_for_update().one_or_none() if lock else query.one_or_none() + if row is None: + raise WikiNotFoundError("Wiki page not found.") + space = ( + session.query(WikiSpace) + .filter(WikiSpace.tenant_id == row.tenant_id, WikiSpace.id == row.space_id) + .one() + ) + if edit: + _require_space_write(principal, space) + if not _access( + row.inherits_access, row.visibility, row.acl_tokens, space, principal + ): + raise PermissionError("Wiki page access is restricted.") + elif govern: + if not _access( + row.inherits_access, row.visibility, row.acl_tokens, space, principal + ): + raise PermissionError("Wiki page access is restricted.") + elif not can_read_page(session, principal, row, space=space): + raise WikiNotFoundError("Wiki page not found.") + return row, space + + +def _parent( + session: Session, principal: object, space: WikiSpace, page_id: str | None +) -> WikiPage | None: + if page_id is None: + return None + row = ( + session.query(WikiPage) + .filter( + WikiPage.tenant_id == space.tenant_id, + WikiPage.space_id == space.id, + WikiPage.id == page_id, + ) + .one_or_none() + ) + if row is None or not can_read_page(session, principal, row, space=space): + raise WikiStoreError("Parent page must be accessible in the same space.") + if row.state == "archived": + raise WikiStoreError("Archived pages cannot be parents.") + return row + + +def _require_space_write(principal: object, space: WikiSpace) -> None: + _require_scope(principal, WRITE_SCOPE) + if not _access(True, "inherit", (), space, principal): + raise PermissionError("Wiki space is not writable by this principal.") + + +def _access( + inherits: bool, + visibility: str, + tokens: Sequence[str], + space: WikiSpace, + principal: object, +) -> bool: + if _has_scope(principal, ADMIN_SCOPE): + return True + effective_visibility = space.visibility if inherits else visibility + effective_tokens = space.acl_tokens if inherits else tokens + return effective_visibility == "tenant" or bool( + set(effective_tokens or ()) & set(_principal_tokens(principal)) + ) + + +def _editor(principal: object) -> bool: + return _has_any_scope(principal, WRITE_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE) + + +def _page_payload(row: WikiPage, *, space: WikiSpace) -> dict[str, object]: + visibility = space.visibility if row.inherits_access else row.visibility + acl_tokens = ( + list(space.acl_tokens or ()) + if row.inherits_access + else list(row.acl_tokens or ()) + ) + return { + "page_id": row.id, + "tenant_id": row.tenant_id, + "space_id": row.space_id, + "parent_page_id": row.parent_page_id, + "slug": row.slug, + "path": row.path, + "revision": row.revision, + "published_revision": row.published_revision, + "state": row.state, + "title": row.title, + "body": row.body, + "summary": row.summary, + "inherits_access": row.inherits_access, + "visibility": row.visibility, + "effective_visibility": visibility, + "acl_tokens": list(row.acl_tokens or ()), + "effective_acl_tokens": acl_tokens, + "labels": list(row.labels or ()), + "links": list(row.links or ()), + "redirect_page_id": row.redirect_page_id, + "published_at": _iso(row.published_at), + "archived_at": _iso(row.archived_at), + "created_by": row.created_by, + "updated_by": row.updated_by, + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + } + + +def _published_payload( + revision: WikiPageRevision, space: WikiSpace +) -> dict[str, object]: + payload = dict(revision.snapshot) + if bool(payload.get("inherits_access")): + payload["effective_visibility"] = space.visibility + payload["effective_acl_tokens"] = list(space.acl_tokens or ()) + return payload + + +def _space_payload(row: WikiSpace) -> dict[str, object]: + return { + "space_id": row.id, + "tenant_id": row.tenant_id, + "space_key": row.space_key, + "revision": row.revision, + "title": row.title, + "description": row.description, + "visibility": row.visibility, + "acl_tokens": list(row.acl_tokens or ()), + "publish_mode": row.publish_mode, + "archived_at": _iso(row.archived_at), + "created_by": row.created_by, + "updated_by": row.updated_by, + "created_at": _iso(row.created_at), + "updated_at": _iso(row.updated_at), + } + + +def _comment_payload(row: WikiComment) -> dict[str, object]: + return { + "comment_id": row.comment_id, + "page_id": row.page_id, + "page_revision": row.page_revision, + "body": row.body, + "created_by": row.created_by, + "recorded_at": _iso(row.recorded_at), + } + + +def _append_space_history( + session: Session, + row: WikiSpace, + event: str, + at: datetime, + actor: str | None, + reason: str, + key: str, + request_hash: str, + snapshot: dict[str, object], +) -> None: + session.add( + WikiSpaceHistory( + tenant_id=row.tenant_id, + space_id=row.id, + revision=row.revision, + event_type=event, + recorded_at=at, + actor_id=actor, + change_reason=reason, + idempotency_key=key, + request_sha256=request_hash, + snapshot=snapshot, + ) + ) + session.flush() + + +def _append_page_revision( + session: Session, + row: WikiPage, + event: str, + at: datetime, + actor: str | None, + reason: str, + key: str, + request_hash: str, + snapshot: dict[str, object], +) -> None: + session.add( + WikiPageRevision( + tenant_id=row.tenant_id, + page_id=row.id, + revision=row.revision, + event_type=event, + recorded_at=at, + actor_id=actor, + change_reason=reason, + idempotency_key=key, + request_sha256=request_hash, + snapshot=snapshot, + ) + ) + session.flush() + + +def _space_replay( + session: Session, tenant_id: str, key: str, request_hash: str +) -> dict[str, object] | None: + row = ( + session.query(WikiSpaceHistory) + .filter( + WikiSpaceHistory.tenant_id == tenant_id, + WikiSpaceHistory.idempotency_key == key, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != request_hash: + raise WikiConflictError( + "Idempotency key was already used with a different space request." + ) + return dict(row.snapshot) + + +def _page_replay( + session: Session, tenant_id: str, key: str, request_hash: str +) -> dict[str, object] | None: + row = ( + session.query(WikiPageRevision) + .filter( + WikiPageRevision.tenant_id == tenant_id, + WikiPageRevision.idempotency_key == key, + ) + .one_or_none() + ) + if row is None: + return None + if row.request_sha256 != request_hash: + raise WikiConflictError( + "Idempotency key was already used with a different page request." + ) + return dict(row.snapshot) + + +def _revision( + session: Session, row: WikiPage, revision: int | None +) -> WikiPageRevision | None: + if revision is None: + return None + return ( + session.query(WikiPageRevision) + .filter( + WikiPageRevision.tenant_id == row.tenant_id, + WikiPageRevision.page_id == row.id, + WikiPageRevision.revision == revision, + ) + .one_or_none() + ) + + +def _labels(values: object) -> list[str]: + if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): + raise WikiStoreError("Page labels must be a list.") + labels = tuple( + dict.fromkeys(_bounded(item, "Page label", 120).casefold() for item in values) + ) + if len(labels) > 50: + raise WikiStoreError("A Wiki page supports at most 50 labels.") + return list(labels) + + +def _links(values: object) -> list[dict[str, object]]: + if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): + raise WikiStoreError("Page links must be a list.") + if len(values) > 200: + raise WikiStoreError("A Wiki page supports at most 200 links.") + links: list[dict[str, object]] = [] + seen: set[str] = set() + for raw in values: + if not isinstance(raw, Mapping): + raise WikiStoreError("Each Wiki link must be an object.") + link_id = _bounded(raw.get("link_id"), "Link id", 255) + if link_id in seen: + raise WikiStoreError("Wiki link identifiers must be unique per page.") + seen.add(link_id) + url = str(raw.get("url") or "").strip() + if url and not ( + url.startswith("/") + or url.startswith("https://") + or url.startswith("http://") + ): + raise WikiStoreError("Wiki link URLs must be local or HTTP(S).") + owner = _bounded(raw.get("owner_module"), "Link owner module", 120) + item = { + "link_id": link_id, + "kind": _choice( + raw.get("kind", "reference"), + {"attachment", "related", "reference", "external"}, + "link kind", + ), + "owner_module": owner, + "resource_type": _bounded( + raw.get("resource_type"), "Link resource type", 120 + ), + "resource_id": _bounded(raw.get("resource_id"), "Link resource id", 255), + "label": _optional(raw.get("label"), 500), + "url": url or None, + "external_system": _optional(raw.get("external_system"), 120), + "external_id": _optional(raw.get("external_id"), 255), + "provenance": dict(raw.get("provenance") or {}), + } + if item["kind"] == "attachment" and owner not in {"files", "dms"}: + raise WikiStoreError( + "Wiki attachments must reference Files or DMS resources." + ) + links.append(item) + return links + + +def _acl(values: object) -> list[str]: + if not isinstance(values, Sequence) or isinstance(values, (str, bytes)): + raise WikiStoreError("ACL tokens must be a list.") + tokens = tuple(dict.fromkeys(_bounded(item, "ACL token", 320) for item in values)) + if len(tokens) > 500: + raise WikiStoreError("An ACL supports at most 500 subject tokens.") + for token in tokens: + prefix, separator, subject_id = token.partition(":") + if not separator or prefix not in _ACL_PREFIXES or not subject_id: + raise WikiStoreError( + "ACL tokens require a supported subject prefix and identifier." + ) + return list(tokens) + + +def _principal_tokens(principal: object) -> tuple[str, ...]: + values: list[str] = [] + for prefix, attribute in ( + ("account", "account_id"), + ("identity", "identity_id"), + ("membership", "membership_id"), + ("function_assignment", "acting_assignment_id"), + ): + value = getattr(principal, attribute, None) + if str(value or "").strip(): + values.append(f"{prefix}:{value}") + for prefix, attribute in ( + ("group", "group_ids"), + ("role", "role_ids"), + ("function_assignment", "function_assignment_ids"), + ): + values.extend( + f"{prefix}:{item}" + for item in getattr(principal, attribute, ()) or () + if str(item or "").strip() + ) + return tuple(dict.fromkeys(values)) + + +def _search_text(value: Mapping[str, object]) -> str: + links = value.get("links") or () + link_labels = [ + str(item.get("label") or item.get("resource_id") or "") + for item in links + if isinstance(item, Mapping) + ] + return "\n".join( + [ + str(value.get("title") or ""), + str(value.get("body") or ""), + str(value.get("summary") or ""), + *(str(item) for item in value.get("labels") or ()), + *link_labels, + ] + ).casefold() + + +def _emit( + session: Session, + registry: object | None, + tenant_id: str, + event_type: str, + resource_type: str, + resource_id: str, + label: str, + revision: int, + actor: str | None, + at: datetime, +) -> None: + emit_platform_event( + session, + PlatformEvent( + type=event_type, + module_id="wiki", + payload={"resource_id": resource_id, "revision": revision}, + occurred_at=at, + actor=EventActorRef(type="account", id=actor), + tenant=EventTenantRef(id=tenant_id), + subject=EventObjectRef(type=resource_type, id=resource_id, label=label), + resource=EventObjectRef(type=resource_type, id=resource_id, label=label), + classification="restricted", + ), + registry=registry, + ) + + +def _active_modules(registry: object | None) -> frozenset[str]: + if registry is None: + return frozenset() + method = getattr(registry, "active_module_ids", None) + return ( + frozenset(str(item) for item in method()) if callable(method) else frozenset() + ) + + +def _tenant(principal: object) -> str: + value = str(getattr(principal, "tenant_id", "") or "").strip() + if not value: + raise WikiStoreError("Wiki operations require a tenant-bound principal.") + return value + + +def _actor(principal: object) -> str | None: + for value in ( + getattr(principal, "account_id", None), + getattr(principal, "identity_id", None), + getattr(getattr(principal, "user", None), "id", None), + ): + if str(value or "").strip(): + return str(value) + return None + + +def _has_scope(principal: object, scope: str) -> bool: + method = getattr(principal, "has", None) + return ( + bool(method(scope)) + if callable(method) + else scopes_grant_compatible( + frozenset(getattr(principal, "scopes", ()) or ()), scope + ) + ) + + +def _has_any_scope(principal: object, *scopes: str) -> bool: + return any(_has_scope(principal, scope) for scope in scopes) + + +def _require_scope(principal: object, scope: str) -> None: + if not _has_scope(principal, scope): + raise PermissionError(f"Wiki operation requires {scope}.") + + +def _require_any_scope(principal: object, *scopes: str) -> None: + if not any(_has_scope(principal, item) for item in scopes): + raise PermissionError("Wiki operation is not permitted.") + + +def _choice( + value: object, choices: Sequence[str] | set[str] | frozenset[str], label: str +) -> str: + clean = str(value or "").strip() + if clean not in choices: + raise WikiStoreError(f"Unsupported Wiki {label}.") + return clean + + +def _visibility(value: object) -> str: + return _choice(value, _VISIBILITIES, "visibility") + + +def _restricted_requires_acl(visibility: object, tokens: object) -> None: + if visibility == "restricted" and not tokens: + raise WikiStoreError("Restricted Wiki content requires at least one ACL token.") + + +def _slug(value: object, label: str, maximum: int) -> str: + clean = _bounded(value, label, maximum).casefold() + if not _SLUG.fullmatch(clean): + raise WikiStoreError( + f"{label} must use lower-case letters, digits, and single hyphens." + ) + return clean + + +def _bounded(value: object, label: str, maximum: int) -> str: + clean = str(value or "").strip() + if not clean or len(clean) > maximum: + raise WikiStoreError(f"{label} must contain 1 to {maximum} characters.") + return clean + + +def _optional(value: object, maximum: int) -> str: + clean = str(value or "").strip() + if len(clean) > maximum: + raise WikiStoreError(f"Wiki text exceeds {maximum} characters.") + return clean + + +def _aware(value: datetime, label: str) -> datetime: + if value.tzinfo is None or value.utcoffset() is None: + raise WikiStoreError(f"{label} must include a timezone.") + return value + + +def _sha(value: object) -> str: + def normalize(item: object) -> Any: + if isinstance(item, datetime): + return item.astimezone(UTC).isoformat() + if isinstance(item, Mapping): + return {str(key): normalize(val) for key, val in item.items()} + if isinstance(item, (list, tuple)): + return [normalize(val) for val in item] + return item + + return hashlib.sha256( + json.dumps( + normalize(value), sort_keys=True, separators=(",", ":"), ensure_ascii=True + ).encode() + ).hexdigest() + + +def _iso(value: datetime | None) -> str | None: + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + value = value.replace(tzinfo=UTC) + return value.isoformat() if value else None + + +__all__ = [ + "ADMIN_SCOPE", + "CAPABILITY_WIKI_REGISTRY", + "COMMENT_SCOPE", + "PUBLISH_SCOPE", + "READ_SCOPE", + "SqlWikiRegistry", + "WRITE_SCOPE", + "WikiConflictError", + "WikiNotFoundError", + "WikiStoreError", + "add_comment", + "archive_page", + "archive_space", + "can_read_page", + "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", +] diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 22cd2f4..f3559df 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -2,37 +2,45 @@ from __future__ import annotations import unittest -from govoplan_wiki.backend.manifest import ( +from govoplan_wiki.backend.dsar_provider import WIKI_DSAR_CAPABILITY +from govoplan_wiki.backend.manifest import get_manifest +from govoplan_wiki.backend.service import ( ADMIN_SCOPE, + CAPABILITY_WIKI_REGISTRY, + COMMENT_SCOPE, + PUBLISH_SCOPE, READ_SCOPE, WRITE_SCOPE, - get_manifest, ) class WikiManifestTests(unittest.TestCase): - def test_manifest_registers_domain_seed(self) -> None: + def test_manifest_registers_complete_vertical_slice(self) -> None: manifest = get_manifest() - self.assertEqual("wiki", manifest.id) + self.assertEqual("0.1.20", manifest.version) self.assertEqual(("access",), manifest.dependencies) self.assertEqual( - {READ_SCOPE, WRITE_SCOPE, ADMIN_SCOPE}, - {permission.scope for permission in manifest.permissions}, + {READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE, PUBLISH_SCOPE, ADMIN_SCOPE}, + {item.scope for item in manifest.permissions}, ) - self.assertIn("search", manifest.optional_dependencies) - self.assertTrue(manifest.documentation) - topic = manifest.documentation[0] - self.assertEqual("reference", topic.metadata["kind"]) - self.assertIn("seed_boundary", topic.metadata["consequence_classes"]) - self.assertTrue( - all( - topic.translations.get("de", {}).get(field) - for field in ("title", "summary", "body") - ) + self.assertEqual( + {CAPABILITY_WIKI_REGISTRY, WIKI_DSAR_CAPABILITY}, + {item.name for item in manifest.provides_interfaces}, ) - self.assertIsNone(manifest.route_factory) - self.assertIsNone(manifest.frontend) + self.assertIsNotNone(manifest.route_factory) + self.assertEqual("@govoplan/wiki-webui", manifest.frontend.package_name) + self.assertEqual( + ("wiki.pages",), tuple(item.id for item in manifest.search_sources) + ) + self.assertIsNotNone(manifest.migration_spec) + self.assertTrue(manifest.migration_spec.retirement_supported) + self.assertTrue(manifest.uninstall_guard_providers) + self.assertEqual("vertical_slice", manifest.architecture.maturity) + self.assertGreaterEqual(len(manifest.documentation), 5) + for topic in manifest.documentation: + self.assertIn("de", topic.translations) + self.assertTrue(topic.metadata.get("kind")) if __name__ == "__main__": diff --git a/tests/test_wiki_service.py b/tests/test_wiki_service.py new file mode 100644 index 0000000..330a247 --- /dev/null +++ b/tests/test_wiki_service.py @@ -0,0 +1,390 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +import unittest + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_core.core.dsar import DsarSubjectRef +from govoplan_core.core.search import SearchBackfillRequest +from govoplan_core.db.base import Base +from govoplan_wiki.backend.db.models import ( + WikiComment, + WikiPageRevision, + WikiSpaceHistory, +) +from govoplan_wiki.backend.dsar_provider import WikiDsarProvider +from govoplan_wiki.backend.search_source import ( + PROVIDER_ID, + RESOURCE_TYPE, + WikiSearchSource, +) +from govoplan_wiki.backend.service import ( + ADMIN_SCOPE, + COMMENT_SCOPE, + PUBLISH_SCOPE, + READ_SCOPE, + WRITE_SCOPE, + WikiConflictError, + add_comment, + archive_page, + compare_revisions, + create_page, + create_space, + get_page, + list_comments, + list_pages, + page_revisions, + publish_page, + update_page, +) + + +NOW = datetime(2026, 8, 22, 10, 0, tzinfo=UTC) + + +class _Principal: + def __init__( + self, + account_id: str, + scopes: set[str], + tenant_id: str = "tenant-1", + groups: tuple[str, ...] = (), + ) -> None: + self.account_id = account_id + self.identity_id = None + self.membership_id = f"membership-{account_id}" + self.tenant_id = tenant_id + self.scopes = frozenset(scopes) + self.group_ids = frozenset(groups) + self.role_ids = frozenset() + self.function_assignment_ids = frozenset() + self.acting_assignment_id = None + self.user = SimpleNamespace(id=account_id) + + def has(self, scope: str) -> bool: + return scope in self.scopes + + +class WikiServiceTests(unittest.TestCase): + def setUp(self) -> None: + engine = create_engine("sqlite+pysqlite:///:memory:", future=True) + Base.metadata.create_all(engine) + self.Session = sessionmaker(bind=engine, expire_on_commit=False) + all_scopes = { + READ_SCOPE, + WRITE_SCOPE, + COMMENT_SCOPE, + PUBLISH_SCOPE, + ADMIN_SCOPE, + } + self.admin = _Principal("admin-1", all_scopes) + self.editor = _Principal( + "editor-1", {READ_SCOPE, WRITE_SCOPE, COMMENT_SCOPE}, groups=("knowledge",) + ) + self.publisher = _Principal( + "publisher-1", + {READ_SCOPE, PUBLISH_SCOPE}, + groups=("knowledge",), + ) + self.reader = _Principal("reader-1", {READ_SCOPE}, groups=("knowledge",)) + self.outsider = _Principal("outsider-1", {READ_SCOPE}) + + def test_publication_keeps_last_published_revision_visible_during_new_draft( + self, + ) -> None: + with self.Session() as session: + self._space(session) + created = self._page(session) + published = publish_page( + session, + self.publisher, + page_id="page-1", + expected_revision=created["revision"], + recorded_at=NOW + timedelta(minutes=1), + change_reason="Reviewed for publication.", + idempotency_key="publish-1", + ) + publish_replay = publish_page( + session, + self.publisher, + page_id="page-1", + expected_revision=created["revision"], + recorded_at=NOW + timedelta(minutes=1), + change_reason="Reviewed for publication.", + idempotency_key="publish-1", + ) + session.commit() + self.assertEqual(published, publish_replay) + self.assertEqual(2, published["published_revision"]) + self.assertEqual( + "Published body", + get_page(session, self.reader, page_id="page-1")["body"], + ) + + draft = update_page( + session, + self.editor, + page_id="page-1", + expected_revision=2, + changes={"body": "Next draft body", "labels": ["Guide", "Updated"]}, + recorded_at=NOW + timedelta(minutes=2), + change_reason="Prepare the next edition.", + idempotency_key="edit-1", + ) + draft_replay = update_page( + session, + self.editor, + page_id="page-1", + expected_revision=2, + changes={"body": "Next draft body", "labels": ["Guide", "Updated"]}, + recorded_at=NOW + timedelta(minutes=2), + change_reason="Prepare the next edition.", + idempotency_key="edit-1", + ) + session.commit() + self.assertEqual(draft, draft_replay) + self.assertEqual("draft", draft["state"]) + self.assertEqual( + "Next draft body", + get_page(session, self.editor, page_id="page-1")["body"], + ) + reader_view = get_page(session, self.reader, page_id="page-1") + self.assertEqual("Published body", reader_view["body"]) + self.assertEqual(2, reader_view["revision"]) + + source = WikiSearchSource() + page = source.backfill( + session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="wiki-test", + limit=50, + ), + ) + self.assertEqual(1, len(page.documents)) + self.assertEqual("Published body", page.documents[0].body) + self.assertEqual("2", page.documents[0].source_revision) + diff = compare_revisions( + session, self.editor, page_id="page-1", from_revision=2, to_revision=3 + ) + self.assertIn("Next draft body", diff["diff"]) + self.assertEqual( + 3, len(page_revisions(session, self.editor, page_id="page-1")) + ) + + def test_hierarchy_acl_occ_replay_links_comments_and_archive_are_governed( + self, + ) -> None: + with self.Session() as session: + self._space(session) + created = self._page(session) + replay = self._page(session) + self.assertEqual(created, replay) + self.assertEqual(1, session.query(WikiPageRevision).count()) + self.assertIsNone(get_page(session, self.outsider, page_id="page-1")) + self.assertEqual(0, list_pages(session, self.reader, space_id="space-1")[1]) + other_tenant = _Principal( + "reader-1", {READ_SCOPE}, tenant_id="tenant-2", groups=("knowledge",) + ) + self.assertIsNone(get_page(session, other_tenant, page_id="page-1")) + session.commit() + + with self.assertRaises(WikiConflictError): + update_page( + session, + self.editor, + page_id="page-1", + expected_revision=99, + changes={"title": "Stale"}, + recorded_at=NOW + timedelta(minutes=1), + change_reason="Stale edit.", + idempotency_key="stale-1", + ) + session.rollback() + + comment = add_comment( + session, + self.editor, + page_id="page-1", + expected_revision=1, + comment_id="comment-1", + body="Please verify the source.", + recorded_at=NOW + timedelta(minutes=2), + idempotency_key="comment-1", + ) + replay_comment = add_comment( + session, + self.editor, + page_id="page-1", + expected_revision=1, + comment_id="comment-1", + body="Please verify the source.", + recorded_at=NOW + timedelta(minutes=2), + idempotency_key="comment-1", + ) + self.assertEqual(comment, replay_comment) + self.assertEqual( + 1, len(list_comments(session, self.editor, page_id="page-1")) + ) + self.assertEqual(1, session.query(WikiComment).count()) + + published = publish_page( + session, + self.publisher, + page_id="page-1", + expected_revision=1, + recorded_at=NOW + timedelta(minutes=3), + change_reason="Approved.", + idempotency_key="publish-archive", + ) + replay_after_revision = add_comment( + session, + self.editor, + page_id="page-1", + expected_revision=1, + comment_id="comment-1", + body="Please verify the source.", + recorded_at=NOW + timedelta(minutes=2), + idempotency_key="comment-1", + ) + self.assertEqual(comment, replay_after_revision) + archived = archive_page( + session, + self.publisher, + page_id="page-1", + expected_revision=published["revision"], + recorded_at=NOW + timedelta(minutes=4), + change_reason="Superseded.", + idempotency_key="archive-1", + ) + session.commit() + self.assertEqual("archived", archived["state"]) + self.assertIsNone(get_page(session, self.reader, page_id="page-1")) + self.assertEqual( + 0, + len( + WikiSearchSource() + .backfill( + session, + request=SearchBackfillRequest( + tenant_id="tenant-1", + provider_id=PROVIDER_ID, + resource_type=RESOURCE_TYPE, + rebuild_id="wiki-archive-test", + limit=10, + ), + ) + .documents + ), + ) + + def test_dsar_exports_only_exact_subject_participation_and_manual_review( + self, + ) -> None: + with self.Session() as session: + self._space(session) + self._page(session) + add_comment( + session, + self.editor, + page_id="page-1", + expected_revision=1, + comment_id="comment-dsar", + body="My attributable contribution.", + recorded_at=NOW + timedelta(minutes=1), + idempotency_key="comment-dsar", + ) + session.commit() + provider = WikiDsarProvider() + subject = DsarSubjectRef(account_id="editor-1") + records = provider.search_subject( + session, tenant_id="tenant-1", subject=subject + ) + self.assertEqual(1, len(records)) + self.assertEqual( + "My attributable contribution.", + records[0].data["subject_comments"][0]["body"], + ) + self.assertNotIn("body", records[0].data) + self.assertEqual( + (), + provider.search_subject( + session, + tenant_id="tenant-1", + subject=DsarSubjectRef( + account_id="editor-1", + external_references={"wiki.account": "different-account"}, + ), + ), + ) + actions = provider.plan_erasure( + session, tenant_id="tenant-1", subject=subject, records=records + ) + self.assertEqual("manual_review", actions[0].kind) + self.assertFalse(actions[0].executable) + result = provider.execute_erasure( + session, + tenant_id="tenant-1", + subject=subject, + actions=actions, + request_id="dsar-1", + ) + self.assertEqual("blocked", result[0].status) + + def _space(self, session): + item = create_space( + session, + self.admin, + space_id="space-1", + space_key="knowledge", + title="Knowledge", + description="Governed knowledge", + visibility="restricted", + acl_tokens=["group:knowledge"], + publish_mode="publishers", + recorded_at=NOW, + change_reason="Create governed space.", + idempotency_key="space-1", + ) + self.assertEqual(1, session.query(WikiSpaceHistory).count()) + return item + + def _page(self, session): + return create_page( + session, + self.editor, + page_id="page-1", + space_id="space-1", + parent_page_id=None, + slug="service-guide", + title="Service guide", + body="Published body", + summary="How to deliver the service.", + inherits_access=True, + visibility="tenant", + acl_tokens=[], + labels=["Guide"], + links=[ + { + "link_id": "file-1", + "kind": "attachment", + "owner_module": "files", + "resource_type": "file", + "resource_id": "file-1", + "label": "Source", + "url": "/files/file-1", + } + ], + recorded_at=NOW, + change_reason="Create the guide.", + idempotency_key="page-1", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/package.json b/webui/package.json new file mode 100644 index 0000000..3a2a319 --- /dev/null +++ b/webui/package.json @@ -0,0 +1,31 @@ +{ + "name": "@govoplan/wiki-webui", + "version": "0.1.20", + "private": true, + "type": "module", + "main": "src/index.ts", + "module": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + }, + "./styles/wiki.css": "./src/styles/wiki.css" + }, + "scripts": { + "test:interface-pattern": "node scripts/test-interface-pattern.mjs" + }, + "peerDependencies": { + "@govoplan/core-webui": "^0.1.31", + "lucide-react": "^1.23.0", + "react": ">=19.2.7 <20", + "react-dom": ">=19.2.7 <20", + "react-router": ">=8.3.0 <9" + }, + "peerDependenciesMeta": { + "@govoplan/core-webui": { + "optional": true + } + } +} diff --git a/webui/scripts/test-interface-pattern.mjs b/webui/scripts/test-interface-pattern.mjs new file mode 100644 index 0000000..28dfb04 --- /dev/null +++ b/webui/scripts/test-interface-pattern.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; + +const page = fs.readFileSync("src/features/wiki/WikiPage.tsx", "utf8"); +const styles = fs.readFileSync("src/styles/wiki.css", "utf8"); + +assert.ok(page.includes("WorkspaceActionBar"), "Wiki uses the semantic shared action bar"); +assert.ok(page.includes("refreshable"), "The refreshable Wiki workspace exposes Reload"); +assert.ok(page.includes("DocumentationHelpLink"), "Wiki exposes configured-system help"); +assert.ok(page.includes("PageScrollViewport"), "Wiki owns bounded tree and detail scrolling"); +assert.ok(page.includes("SelectionList"), "Wiki uses the shared semantic selection list"); +assert.ok(page.includes(']*\bonClick\s*=/.test(page), "Wiki uses semantic interactive elements"); +assert.ok(styles.includes("@media (max-width: 760px)"), "Wiki retains a responsive tree-detail layout"); +assert.ok(styles.includes(":focus-visible"), "Wiki retains visible keyboard focus"); + +console.log("Wiki interface pattern contract passed."); diff --git a/webui/src/api/wiki.ts b/webui/src/api/wiki.ts new file mode 100644 index 0000000..d4d3dcc --- /dev/null +++ b/webui/src/api/wiki.ts @@ -0,0 +1,147 @@ +import { apiFetch, apiPath, type ApiSettings } from "@govoplan/core-webui"; + + +export type WikiLink = { + link_id: string; + kind: "attachment" | "related" | "reference" | "external"; + owner_module: string; + resource_type: string; + resource_id: string; + label?: string | null; + url?: string | null; + external_system?: string | null; + external_id?: string | null; + provenance: Record; +}; + +export type WikiSpace = { + space_id: string; + tenant_id: string; + space_key: string; + revision: number; + title: string; + description: string; + visibility: "tenant" | "restricted"; + acl_tokens: string[]; + publish_mode: "publishers" | "editors"; + archived_at?: string | null; +}; + +export type WikiPageRecord = { + page_id: string; + tenant_id: string; + space_id: string; + parent_page_id?: string | null; + slug: string; + path: string; + revision: number; + published_revision?: number | null; + state: "draft" | "published" | "redirected" | "archived"; + title: string; + body: string; + summary: string; + inherits_access: boolean; + visibility: "inherit" | "tenant" | "restricted"; + effective_visibility: "tenant" | "restricted"; + acl_tokens: string[]; + effective_acl_tokens: string[]; + labels: string[]; + links: WikiLink[]; + redirect_page_id?: string | null; + published_at?: string | null; + archived_at?: string | null; +}; + +export type WikiRevision = { + revision: number; + event_type: string; + recorded_at: string; + actor_id?: string | null; + change_reason: string; + snapshot: WikiPageRecord; +}; + +export type WikiComment = { + comment_id: string; + page_id: string; + page_revision: number; + body: string; + created_by?: string | null; + recorded_at: string; +}; + +export type WikiAvailability = { + files: boolean; + dms: boolean; + search: boolean; + templates: boolean; + workflow_engine: boolean; + projects: boolean; + cases: boolean; + notifications: boolean; + consequences: Record; +}; + +export function getWikiAvailability(settings: ApiSettings, signal?: AbortSignal): Promise { + return apiFetch(settings, "/api/v1/wiki/availability", { signal }); +} + +export function listWikiSpaces(settings: ApiSettings, signal?: AbortSignal): Promise<{ spaces: WikiSpace[] }> { + return apiFetch(settings, "/api/v1/wiki/spaces", { signal }); +} + +export function createWikiSpace(settings: ApiSettings, values: Omit, description: string): Promise { + return apiFetch(settings, "/api/v1/wiki/spaces", { method: "POST", body: JSON.stringify({ ...values, description, recorded_at: new Date().toISOString(), change_reason: "Created governed Wiki space.", idempotency_key: crypto.randomUUID() }) }); +} + +export function updateWikiSpace(settings: ApiSettings, record: WikiSpace, changes: Record, reason: string): Promise { + return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) }); +} + +export function archiveWikiSpace(settings: ApiSettings, record: WikiSpace): Promise { + return apiFetch(settings, `/api/v1/wiki/spaces/${encodeURIComponent(record.space_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived Wiki space.")) }); +} + +export function listWikiPages(settings: ApiSettings, options: { spaceId?: string; query?: string; includeArchived?: boolean } = {}, signal?: AbortSignal): Promise<{ pages: WikiPageRecord[]; total: number }> { + return apiFetch(settings, apiPath("/api/v1/wiki/pages", { space_id: options.spaceId, query: options.query, include_archived: options.includeArchived, limit: 200 }), { signal }); +} + +export function getWikiPage(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}`, { signal }); +} + +export function createWikiPage(settings: ApiSettings, values: Omit, reason: string): Promise { + return apiFetch(settings, "/api/v1/wiki/pages", { method: "POST", body: JSON.stringify({ ...values, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID() }) }); +} + +export function updateWikiPage(settings: ApiSettings, record: WikiPageRecord, changes: Record, reason: string): Promise { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}`, { method: "PATCH", body: JSON.stringify(mutation(record.revision, reason, changes)) }); +} + +export function publishWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/publish`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Published reviewed Wiki revision.")) }); +} + +export function archiveWikiPage(settings: ApiSettings, record: WikiPageRecord): Promise { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/archive`, { method: "POST", body: JSON.stringify(mutation(record.revision, "Archived superseded Wiki page.")) }); +} + +export function listWikiRevisions(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ revisions: WikiRevision[] }> { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/revisions`, { signal }); +} + +export function compareWikiRevisions(settings: ApiSettings, pageId: string, fromRevision: number, toRevision: number): Promise<{ diff: string; truncated: boolean }> { + return apiFetch(settings, apiPath(`/api/v1/wiki/pages/${encodeURIComponent(pageId)}/compare`, { from_revision: fromRevision, to_revision: toRevision })); +} + +export function listWikiComments(settings: ApiSettings, pageId: string, signal?: AbortSignal): Promise<{ comments: WikiComment[] }> { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(pageId)}/comments`, { signal }); +} + +export function addWikiComment(settings: ApiSettings, record: WikiPageRecord, body: string): Promise { + return apiFetch(settings, `/api/v1/wiki/pages/${encodeURIComponent(record.page_id)}/comments`, { method: "POST", body: JSON.stringify({ expected_revision: record.revision, comment_id: crypto.randomUUID(), body, recorded_at: new Date().toISOString(), idempotency_key: crypto.randomUUID() }) }); +} + +function mutation(revision: number, reason: string, changes?: Record) { + return { expected_revision: revision, recorded_at: new Date().toISOString(), change_reason: reason, idempotency_key: crypto.randomUUID(), ...(changes ? { changes } : {}) }; +} diff --git a/webui/src/features/wiki/WikiPage.tsx b/webui/src/features/wiki/WikiPage.tsx new file mode 100644 index 0000000..fbd1272 --- /dev/null +++ b/webui/src/features/wiki/WikiPage.tsx @@ -0,0 +1,282 @@ +import { Archive, BookOpen, FilePlus2, History, Link2, MessageSquarePlus, Pencil, Plus, Search, Send, Settings2, Upload } from "lucide-react"; +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import { Button, ConfirmDialog, Dialog, DocumentationHelpLink, DismissibleAlert, FieldLabel, FilterBar, FormLayout, LoadingIndicator, PageScrollViewport, SelectionList, SelectionListItem, SelectionListItemContent, StatePanel, StatusBadge, WorkspaceActionBar, WorkspaceFrame, hasScope, type PlatformRouteContext } from "@govoplan/core-webui"; +import { addWikiComment, archiveWikiPage, archiveWikiSpace, compareWikiRevisions, createWikiPage, createWikiSpace, getWikiAvailability, getWikiPage, listWikiComments, listWikiPages, listWikiRevisions, listWikiSpaces, publishWikiPage, updateWikiPage, updateWikiSpace, type WikiAvailability, type WikiComment, type WikiLink, type WikiPageRecord, type WikiRevision, type WikiSpace } from "../../api/wiki"; + + +type ArchiveTarget = { kind: "page"; record: WikiPageRecord } | { kind: "space"; record: WikiSpace } | null; + + +export default function WikiPage({ settings, auth }: PlatformRouteContext) { + const [spaces, setSpaces] = useState([]); + const [pages, setPages] = useState([]); + const [selectedSpaceId, setSelectedSpaceId] = useState(""); + const [selectedPageId, setSelectedPageId] = useState(""); + const [query, setQuery] = useState(""); + const [submittedQuery, setSubmittedQuery] = useState(""); + const [revisions, setRevisions] = useState([]); + const [comments, setComments] = useState([]); + const [availability, setAvailability] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [dialogError, setDialogError] = useState(""); + const [spaceDialog, setSpaceDialog] = useState(null); + const [pageDialog, setPageDialog] = useState(null); + const [linkOpen, setLinkOpen] = useState(false); + const [historyOpen, setHistoryOpen] = useState(false); + const [compareText, setCompareText] = useState(""); + const [archiveTarget, setArchiveTarget] = useState(null); + const canWrite = hasScope(auth, "wiki:page:write"); + const canComment = canWrite || hasScope(auth, "wiki:page:comment"); + const canPublish = hasScope(auth, "wiki:page:publish") || hasScope(auth, "wiki:space:admin"); + const canAdmin = hasScope(auth, "wiki:space:admin"); + + async function reload(signal?: AbortSignal, preferredSpaceId?: string) { + setLoading(true); setError(""); + try { + const [spaceResult, integrationState] = await Promise.all([listWikiSpaces(settings, signal), getWikiAvailability(settings, signal)]); + const activeSpace = preferredSpaceId && spaceResult.spaces.some((item) => item.space_id === preferredSpaceId) + ? preferredSpaceId + : selectedSpaceId && spaceResult.spaces.some((item) => item.space_id === selectedSpaceId) ? selectedSpaceId : spaceResult.spaces[0]?.space_id ?? ""; + const pageResult = activeSpace ? await listWikiPages(settings, { spaceId: activeSpace, query: submittedQuery }, signal) : { pages: [], total: 0 }; + setSpaces(spaceResult.spaces); setAvailability(integrationState); setSelectedSpaceId(activeSpace); setPages(pageResult.pages); + setSelectedPageId((current) => pageResult.pages.some((item) => item.page_id === current) ? current : pageResult.pages[0]?.page_id ?? ""); + } catch (reason) { + if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki content could not be loaded.")); + } finally { setLoading(false); } + } + + useEffect(() => { + const controller = new AbortController(); void reload(controller.signal); + return () => controller.abort(); + }, [settings, submittedQuery]); + + useEffect(() => { + if (!selectedSpaceId) return; + const controller = new AbortController(); + setLoading(true); + void listWikiPages(settings, { spaceId: selectedSpaceId, query: submittedQuery }, controller.signal).then((result) => { + setPages(result.pages); setSelectedPageId((current) => result.pages.some((item) => item.page_id === current) ? current : result.pages[0]?.page_id ?? ""); + }).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki pages could not be loaded.")); }).finally(() => setLoading(false)); + return () => controller.abort(); + }, [settings, selectedSpaceId]); + + const selectedSpace = useMemo(() => spaces.find((item) => item.space_id === selectedSpaceId) ?? null, [spaces, selectedSpaceId]); + const selectedPage = useMemo(() => pages.find((item) => item.page_id === selectedPageId) ?? null, [pages, selectedPageId]); + + useEffect(() => { + if (!selectedPageId) { setRevisions([]); setComments([]); return; } + const controller = new AbortController(); + const requests: Promise[] = [getWikiPage(settings, selectedPageId, controller.signal).then((record) => setPages((current) => current.map((item) => item.page_id === record.page_id ? record : item))), listWikiComments(settings, selectedPageId, controller.signal).then((result) => setComments(result.comments))]; + if (canWrite || canPublish) requests.push(listWikiRevisions(settings, selectedPageId, controller.signal).then((result) => setRevisions(result.revisions))); + void Promise.all(requests).catch((reason) => { if ((reason as Error).name !== "AbortError") setError(message(reason, "Wiki page details could not be loaded.")); }); + return () => controller.abort(); + }, [settings, selectedPageId, canWrite, canPublish]); + + function submitSearch(event: FormEvent) { event.preventDefault(); setSubmittedQuery(query.trim()); } + + async function runAction(action: () => Promise, close?: () => void) { + setSaving(true); setDialogError(""); + try { + const saved = await action(); close?.(); await reload(undefined, saved.space_id); setSelectedPageId(saved.page_id); + } catch (reason) { setDialogError(message(reason, "The Wiki action could not be completed.")); } + finally { setSaving(false); } + } + + async function saveSpace(values: SpaceValues) { + setSaving(true); setDialogError(""); + try { + const saved = spaceDialog !== "new" && spaceDialog + ? await updateWikiSpace(settings, spaceDialog, { title: values.title, description: values.description, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.changeReason) + : await createWikiSpace(settings, { space_id: crypto.randomUUID(), space_key: values.spaceKey, title: values.title, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), publish_mode: values.publishMode }, values.description); + setSpaceDialog(null); await reload(undefined, saved.space_id); + } catch (reason) { setDialogError(message(reason, "The Wiki space could not be saved.")); } + finally { setSaving(false); } + } + + async function savePage(values: PageValues) { + if (!selectedSpace) return; + if (pageDialog !== "new" && pageDialog) { + await runAction(() => updateWikiPage(settings, pageDialog, { title: values.title, body: values.body, summary: values.summary, slug: values.slug, parent_page_id: values.parentPageId || null, inherits_access: values.inheritsAccess, visibility: values.visibility, acl_tokens: tokens(values.aclTokens), labels: tokens(values.labels), redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null)); + return; + } + await runAction(() => createWikiPage(settings, { page_id: crypto.randomUUID(), space_id: selectedSpace.space_id, parent_page_id: values.parentPageId || null, slug: values.slug, title: values.title, body: values.body, summary: values.summary, inherits_access: values.inheritsAccess, visibility: values.inheritsAccess ? "inherit" : values.visibility, acl_tokens: values.inheritsAccess ? [] : tokens(values.aclTokens), labels: tokens(values.labels), links: [], redirect_page_id: values.redirectPageId || null }, values.changeReason), () => setPageDialog(null)); + } + + async function addReference(link: WikiLink, reason: string) { + if (!selectedPage) return; + await runAction(() => updateWikiPage(settings, selectedPage, { links: [...selectedPage.links, link] }, reason), () => setLinkOpen(false)); + } + + async function removeReference(linkId: string) { + if (!selectedPage) return; + await runAction(() => updateWikiPage(settings, selectedPage, { links: selectedPage.links.filter((item) => item.link_id !== linkId) }, "Removed a stale Wiki reference.")); + } + + async function addComment(body: string) { + if (!selectedPage) return; + setSaving(true); setDialogError(""); + try { await addWikiComment(settings, selectedPage, body); setComments((await listWikiComments(settings, selectedPage.page_id)).comments); } + catch (reason) { setDialogError(message(reason, "The comment could not be added.")); } + finally { setSaving(false); } + } + + async function showComparison(from: number, to: number) { + if (!selectedPage) return; + setCompareText("Loading comparison..."); + try { setCompareText((await compareWikiRevisions(settings, selectedPage.page_id, from, to)).diff || "The selected revisions have identical text."); } + catch (reason) { setCompareText(message(reason, "Revisions could not be compared.")); } + } + + async function confirmArchive() { + const target = archiveTarget; if (!target) return; + setSaving(true); setDialogError(""); + try { + if (target.kind === "page") { const saved = await archiveWikiPage(settings, target.record); setSelectedPageId(saved.page_id); } + else await archiveWikiSpace(settings, target.record); + setArchiveTarget(null); await reload(); + } catch (reason) { setDialogError(message(reason, "Wiki content could not be archived.")); } + finally { setSaving(false); } + } + + return
+ + void reload(), loading }} contextActions={<> + + + {pages.length} pages + } helpAction={} createAction={
+ {canAdmin && } + {canWrite && selectedSpace && } +
} /> + {error && setError("")}>{error}} + {dialogError && setDialogError("")}>{dialogError}} + {availability && (!availability.search || !availability.files && !availability.dms) &&
+ {!availability.search && {availability.consequences.search}} + {!availability.files && !availability.dms && {availability.consequences.files}} +
} +
+ + {selectedSpace &&
Space

{selectedSpace.title}

{selectedSpace.description}

{canAdmin && }
} + {loading && } + {!loading && pages.length === 0 && } + {pages.map((item) => setSelectedPageId(item.page_id)}>)} + {canAdmin && selectedSpace &&
} +
+ + {selectedPage ? setPageDialog(selectedPage)} onPublish={() => void runAction(() => publishWikiPage(settings, selectedPage))} onAddLink={() => setLinkOpen(true)} onRemoveLink={removeReference} onHistory={() => { setCompareText(""); setHistoryOpen(true); }} onComment={addComment} onArchive={() => setArchiveTarget({ kind: "page", record: selectedPage })} /> : } + +
+
+ setSpaceDialog(null)} onSave={saveSpace} /> + setPageDialog(null)} onSave={savePage} /> + setLinkOpen(false)} onSave={addReference} /> + setHistoryOpen(false)} onCompare={showComparison} /> + setArchiveTarget(null)} onConfirm={() => void confirmArchive()} /> +
; +} + + +function WikiDetail({ record, revisions, comments, canWrite, canPublish, canComment, saving, onEdit, onPublish, onAddLink, onRemoveLink, onHistory, onComment, onArchive }: { record: WikiPageRecord; revisions: WikiRevision[]; comments: WikiComment[]; canWrite: boolean; canPublish: boolean; canComment: boolean; saving: boolean; onEdit: () => void; onPublish: () => void; onAddLink: () => void; onRemoveLink: (id: string) => Promise; onHistory: () => void; onComment: (body: string) => Promise; onArchive: () => void }) { + const [comment, setComment] = useState(""); + return
+
{record.path} · revision {record.revision}

{record.title}

{record.summary}

+
+ {canWrite && record.state !== "archived" && } + {canWrite && record.state !== "archived" && } + {(canWrite || canPublish) && } + {canPublish && record.state === "draft" && } +
+ {record.published_revision && record.state === "draft" &&
Readers and Search still receive published revision {record.published_revision} until this draft is published.
} +
{record.body || This page has no body text yet.}
+ {record.labels.length > 0 &&
    {record.labels.map((label) =>
  • {label}
  • )}
} +

References and attachments {record.links.length}

{record.links.length === 0 ?

No typed references are attached.

:
    {record.links.map((link) =>
  • {link.url ? {link.label || link.resource_id} : {link.label || link.resource_id}}{humanize(link.kind)} · {link.owner_module} · {link.resource_type}
    {canWrite && }
  • )}
}
+

Comments {comments.length}

{comments.length === 0 ?

No comments have been recorded.

:
    {comments.map((item) =>
  1. {item.body}

    {item.created_by || "System actor"} · revision {item.page_revision} · {formatDate(item.recorded_at)}
  2. )}
}{canComment && record.state !== "archived" &&
{ event.preventDefault(); const body = comment.trim(); if (!body) return; void onComment(body).then(() => setComment("")); }}>