Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 145 additions & 12 deletions adapters/selfhosted/xwiki.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,116 @@
"""XWiki adapter — creates spaces and pages via the XWiki REST API."""
"""XWiki adapter — creates spaces and pages via the XWiki REST API.

Templates are authored in Markdown (single source of truth).
This adapter converts Markdown to XWiki 2.1 syntax before uploading.
"""

from __future__ import annotations

import os
import re
from typing import Any

import requests

from src.core.config import CoreConfig


def md_to_xwiki(text: str) -> str:
"""Convert a subset of Markdown to XWiki 2.1 syntax.

Covers the constructs used in qms-kit templates:
headings, bold, italic, tables, unordered lists, blockquotes,
horizontal rules, and inline code.
"""
lines = text.splitlines()
out: list[str] = []
i = 0

while i < len(lines):
line = lines[i]

# Headings: # → =, ## → ==, up to 6 levels
heading = re.match(r"^(#{1,6})\s+(.*)", line)
if heading:
level = len(heading.group(1))
marker = "=" * level
out.append(f"{marker} {heading.group(2).strip()} {marker}")
i += 1
continue

# Horizontal rule: --- or *** or ___
if re.match(r"^(-{3,}|\*{3,}|_{3,})\s*$", line):
out.append("----")
i += 1
continue

# Table rows: | ... |
if line.strip().startswith("|"):
# Skip separator rows like |---|---|
if re.match(r"^\|[\s\-:|]+\|", line):
i += 1
continue
cells = [c.strip() for c in line.strip().strip("|").split("|")]
# Detect header: previous non-empty line was also a table row,
# next line is a separator — check next line
is_header = i + 1 < len(lines) and re.match(r"^\|[\s\-:|]+\|", lines[i + 1])
if is_header:
out.append("|=" + "|=".join(_md_inline(c) for c in cells))
else:
out.append("|" + "|".join(_md_inline(c) for c in cells))
i += 1
continue

# Blockquote: > text → indented paragraph (XWiki has no native blockquote)
if line.startswith(">"):
content = line.lstrip("> ").strip()
out.append(_md_inline(content))
i += 1
continue

# Unordered list: - item or * item (not table)
list_match = re.match(r"^(\s*)[*\-]\s+(.*)", line)
if list_match and not line.strip().startswith("|"):
depth = len(list_match.group(1)) // 2 + 1
out.append("*" * depth + " " + _md_inline(list_match.group(2)))
i += 1
continue

# Ordered list: 1. item
ol_match = re.match(r"^\s*\d+\.\s+(.*)", line)
if ol_match:
out.append("1. " + _md_inline(ol_match.group(1)))
i += 1
continue

# Default: apply inline conversions
out.append(_md_inline(line))
i += 1

return "\n".join(out)


def _md_inline(text: str) -> str:
"""Apply inline Markdown → XWiki 2.1 conversions."""
# Bold+italic: ***text*** → **//text//**
text = re.sub(r"\*{3}(.+?)\*{3}", r"**//\1//**", text)
# Bold: **text** stays **text** in XWiki 2.1
# Italic: *text* → //text// (only single asterisks not inside words)
text = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"//\1//", text)
# Italic: _text_ → //text//
text = re.sub(r"(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", r"//\1//", text)
# Inline code: `code` → {{code}}code{{/code}}
text = re.sub(r"`([^`]+)`", r"{{{{\1}}}}", text)
# Markdown links: [text](url) → [[text>>url]]
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"[[\1>>\2]]", text)
# XWiki internal links passed through: [[...>>...]] already correct
return text


class XWikiAdapter:
"""Seeds QMS document structure into an XWiki instance.

Templates are Markdown; this adapter converts to XWiki 2.1 before upload.
Authentication: username + XWIKI_PASSWORD env var.
API reference: https://www.xwiki.org/xwiki/bin/view/Documentation/UserGuide/Features/XWikiRESTfulAPI
"""
Expand All @@ -23,35 +121,70 @@ def __init__(self, config: CoreConfig) -> None:
xwiki = config.selfhosted.xwiki
self._base_url = xwiki.base_url.rstrip("/")
self._space_key = xwiki.space_key
self._parent_page = xwiki.parent_page
self._username = xwiki.username
self._password = os.environ.get("XWIKI_PASSWORD", "")

@property
def _auth(self) -> tuple[str, str]:
return (self._username, self._password)

def _page_url(self, page_name: str) -> str:
def _page_url(self, page_name: str, parent: str | None = None) -> str:
if parent:
return (
f"{self._base_url}/rest/wikis/xwiki"
f"/spaces/{self._space_key}/spaces/{parent}/pages/{page_name}"
)
return f"{self._base_url}/rest/wikis/xwiki/spaces/{self._space_key}/pages/{page_name}"

def _put(self, url: str, payload: dict[str, Any]) -> None:
resp = requests.put(url, json=payload, auth=self._auth, timeout=30)
resp.raise_for_status()

def page_exists(self, page_name: str) -> bool:
resp = requests.get(self._page_url(page_name), auth=self._auth, timeout=10)
def page_exists(self, page_name: str, parent: str | None = None) -> bool:
resp = requests.get(self._page_url(page_name, parent), auth=self._auth, timeout=10)
return resp.status_code == 200

def create_or_update_page(self, page_name: str, title: str, content: str) -> None:
"""Idempotent: creates the page if absent, updates content if present."""
payload = {"title": title, "content": content, "syntax": "xwiki/2.1"}
self._put(self._page_url(page_name), payload)
def create_or_update_page(
self,
page_name: str,
title: str,
content: str,
parent: str | None = None,
) -> None:
"""Idempotent upsert. Content is Markdown; converted to XWiki 2.1 here."""
xwiki_content = md_to_xwiki(content)
payload = {"title": title, "content": xwiki_content, "syntax": "xwiki/2.1"}
self._put(self._page_url(page_name, parent), payload)

def deploy(self, rendered_pages: dict[str, tuple[str, str]]) -> None:
"""Deploy all rendered pages.
"""Deploy all rendered pages under the parent page hierarchy.

Structure created:
QMS/ ← space root (index page)
QMS/QM Manual/ ← parent page
QMS/QM Manual/<page> ← all document pages

Args:
rendered_pages: mapping of page_name -> (title, rendered_markdown)
rendered_pages: page_name -> (title, rendered_markdown)
"""
# Space index page (the QMS space WebHome equivalent)
index_entry = rendered_pages.pop("qms_index", None)
if index_entry:
title, content = index_entry
self.create_or_update_page("WebHome", title, content)
print(" XWiki: upserted space index 'WebHome'")

# Parent page: "QM Manual" at space root level
parent_name = self._parent_page.replace(" ", "_")
self.create_or_update_page(
parent_name,
self._parent_page,
f"= {self._parent_page} =\n\nAll QMS documents are listed below.",
)
print(f" XWiki: upserted parent page '{self._parent_page}'")

# All document pages nested under the parent
for page_name, (title, content) in rendered_pages.items():
self.create_or_update_page(page_name, title, content)
print(f" XWiki: upserted page '{page_name}'")
self.create_or_update_page(page_name, title, content, parent=parent_name)
print(f" XWiki: upserted page '{page_name}' under '{self._parent_page}'")
12 changes: 11 additions & 1 deletion cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,20 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None:

# Render all templates that have a template key set
rendered: dict[str, tuple[str, str]] = {}

# Space index page
index_content = render_template(
template_name="qms_index.md.j2",
config=config,
templates_dir=TEMPLATES_DIR,
)
rendered["qms_index"] = ("QM Manual", index_content)
click.echo(" Rendered: qms_index (qms_index.md.j2)")

for doc in config.documents:
if doc.template is None:
continue
template_file = f"{doc.template}.xwiki.j2"
template_file = f"{doc.template}.md.j2"
content = render_template(
template_name=template_file,
config=config,
Expand Down
6 changes: 6 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ dev = [
[project.scripts]
qms-kit = "cli.main:cli"

[dependency-groups]
dev = [
"pytest>=9.1.1",
"ruff>=0.15.20",
]

[tool.ruff]
line-length = 100
target-version = "py312"
Expand Down
14 changes: 7 additions & 7 deletions templates/capa_form.md.j2
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# CAPA Form — Corrective Action

| | |
|---|---|
| Field | Value |
|-------|-------|
| **CAPA ID** | {{ capa_id }} |
| **Organisation** | {{ org_name }} |
| **Opened** | {{ opened_date }} |
Expand All @@ -16,7 +16,7 @@

**Problem Description:**

TODO: describe the problem
> **⚠ TODO:** describe the problem

## 2. Immediate Correction

Expand All @@ -26,23 +26,23 @@ TODO: describe the problem

## 3. Root Cause Analysis

**Method:** ☐ 5-Why ☐ Ishikawa ☐ Other: TODO
**Method:** ☐ 5-Why ☐ Ishikawa ☐ Other: TODO

**Root Cause Identified:**

TODO: describe root cause
> **⚠ TODO:** describe root cause

## 4. Corrective Action

| Action | Responsible | Due Date | Done |
|--------|-------------|----------|------|
| TODO | TODO | TODO | ☐ |
| TODO | TODO | TODO | ☐ |

## 5. Effectiveness Review

**Review planned for:** TODO
**Review conducted on:** TODO
**Result:** ☐ Effective ☐ Not effective → new CAPA required
**Result:** ☐ Effective ☐ Not effective → new CAPA required

## 6. Closure

Expand Down
48 changes: 0 additions & 48 deletions templates/capa_form.xwiki.j2

This file was deleted.

42 changes: 21 additions & 21 deletions templates/internal_audit_checklist.md.j2
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Internal Audit — Checklist

| | |
|---|---|
| Field | Value |
|-------|-------|
| **Organisation** | {{ org_name }} |
| **Audit Date** | {{ audit_date }} |
| **Auditor** | {{ auditor }} |
Expand All @@ -14,34 +14,34 @@

| Clause | Check Point | Compliant | Observation / Finding |
|--------|-------------|-----------|----------------------|
| 4.1 | Context analysis documented and up to date? | ☐ Yes ☐ No ☐ N/A | |
| 4.2 | Interested parties register maintained? | ☐ Yes ☐ No ☐ N/A | |
| 4.3 | QMS scope defined? | ☐ Yes ☐ No ☐ N/A | |
| 5.2 | Quality policy communicated and understood? | ☐ Yes ☐ No ☐ N/A | |
| 5.3 | Roles and responsibilities clearly assigned? | ☐ Yes ☐ No ☐ N/A | |
| 6.1 | Risks and opportunities assessed? | ☐ Yes ☐ No ☐ N/A | |
| 6.2 | Quality objectives measurable and documented? | ☐ Yes ☐ No ☐ N/A | |
| 7.2 | Competency matrix current, training evidenced? | ☐ Yes ☐ No ☐ N/A | |
| 7.4 | Communication plan implemented? | ☐ Yes ☐ No ☐ N/A | |
| 7.5 | Document control procedure followed? | ☐ Yes ☐ No ☐ N/A | |
| 8.1 | Processes planned and controlled? | ☐ Yes ☐ No ☐ N/A | |
| 8.2 | Customer requirements determined and met? | ☐ Yes ☐ No ☐ N/A | |
| 8.7 | Nonconformities recorded and addressed? | ☐ Yes ☐ No ☐ N/A | |
| 9.1 | KPIs measured and evaluated? | ☐ Yes ☐ No ☐ N/A | |
| 9.3 | Management review conducted? | ☐ Yes ☐ No ☐ N/A | |
| 10.2 | CAPAs closed on time and verified effective? | ☐ Yes ☐ No ☐ N/A | |
| 4.1 | Context analysis documented and up to date? | ☐ Yes ☐ No ☐ N/A | |
| 4.2 | Interested parties register maintained? | ☐ Yes ☐ No ☐ N/A | |
| 4.3 | QMS scope defined? | ☐ Yes ☐ No ☐ N/A | |
| 5.2 | Quality policy communicated and understood? | ☐ Yes ☐ No ☐ N/A | |
| 5.3 | Roles and responsibilities clearly assigned? | ☐ Yes ☐ No ☐ N/A | |
| 6.1 | Risks and opportunities assessed? | ☐ Yes ☐ No ☐ N/A | |
| 6.2 | Quality objectives measurable and documented? | ☐ Yes ☐ No ☐ N/A | |
| 7.2 | Competency matrix current, training evidenced? | ☐ Yes ☐ No ☐ N/A | |
| 7.4 | Communication plan implemented? | ☐ Yes ☐ No ☐ N/A | |
| 7.5 | Document control procedure followed? | ☐ Yes ☐ No ☐ N/A | |
| 8.1 | Processes planned and controlled? | ☐ Yes ☐ No ☐ N/A | |
| 8.2 | Customer requirements determined and met? | ☐ Yes ☐ No ☐ N/A | |
| 8.7 | Nonconformities recorded and addressed? | ☐ Yes ☐ No ☐ N/A | |
| 9.1 | KPIs measured and evaluated? | ☐ Yes ☐ No ☐ N/A | |
| 9.3 | Management review conducted? | ☐ Yes ☐ No ☐ N/A | |
| 10.2 | CAPAs closed on time and verified effective? | ☐ Yes ☐ No ☐ N/A | |

## Audit Result

**Overall Assessment:** ☐ Conforming ☐ Conditionally conforming ☐ Non-conforming
**Overall Assessment:** ☐ Conforming ☐ Conditionally conforming ☐ Non-conforming

**Key Findings:**

TODO: describe findings
> **⚠ TODO:** describe findings

**Recommendations:**

TODO: recommendations
> **⚠ TODO:** recommendations

---

Expand Down
Loading
Loading