From 4b263b77f9d706783b7281adf7990e842161ff07 Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Sat, 4 Jul 2026 21:22:40 +0200 Subject: [PATCH 1/3] feat(templates): Markdown as single source of truth with XWiki converter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restore all 5 templates to clean Markdown (.md.j2) — removes the per-adapter .xwiki.j2 duplicates from PR #11 - Add templates/qms_index.md.j2: space landing page with document index table and XWiki internal links - Rewrite adapters/selfhosted/xwiki.py: add md_to_xwiki() converter (headings, tables, bold/italic, links, lists, blockquotes) and _md_inline() helper; deploy() now creates the full page hierarchy (WebHome index → QM Manual parent → nested document pages) - Update cli/main.py: renders qms_index.md.j2 first, uses .md.j2 ext - Add tests/test_md_to_xwiki.py: 17 unit tests for the converter - Update tests/test_adapters.py: adjust PUT call count to 3 (parent + 2 doc pages) to match new hierarchy deploy logic - Add pytest as dev dependency Co-Authored-By: Claude Sonnet 4.6 --- adapters/selfhosted/xwiki.py | 165 ++++++++++++++++++-- cli/main.py | 12 +- pyproject.toml | 5 + templates/capa_form.md.j2 | 14 +- templates/capa_form.xwiki.j2 | 48 ------ templates/internal_audit_checklist.md.j2 | 42 ++--- templates/internal_audit_checklist.xwiki.j2 | 42 ----- templates/mgmt_review_agenda.md.j2 | 10 +- templates/mgmt_review_agenda.xwiki.j2 | 35 ----- templates/policy.md.j2 | 17 +- templates/policy.xwiki.j2 | 31 ---- templates/procedure.md.j2 | 16 +- templates/procedure.xwiki.j2 | 44 ------ templates/qms_index.md.j2 | 33 ++++ tests/test_adapters.py | 3 +- tests/test_md_to_xwiki.py | 72 +++++++++ tests/test_renderer.py | 6 +- uv.lock | 8 + 18 files changed, 336 insertions(+), 267 deletions(-) delete mode 100644 templates/capa_form.xwiki.j2 delete mode 100644 templates/internal_audit_checklist.xwiki.j2 delete mode 100644 templates/mgmt_review_agenda.xwiki.j2 delete mode 100644 templates/policy.xwiki.j2 delete mode 100644 templates/procedure.xwiki.j2 create mode 100644 templates/qms_index.md.j2 create mode 100644 tests/test_md_to_xwiki.py diff --git a/adapters/selfhosted/xwiki.py b/adapters/selfhosted/xwiki.py index 8a43c6e..21c19af 100644 --- a/adapters/selfhosted/xwiki.py +++ b/adapters/selfhosted/xwiki.py @@ -1,8 +1,13 @@ -"""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 @@ -10,9 +15,105 @@ 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"(?>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 """ @@ -23,6 +124,7 @@ 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", "") @@ -30,28 +132,65 @@ def __init__(self, config: CoreConfig) -> None: def _auth(self) -> tuple[str, str]: return (self._username, self._password) - def _page_url(self, page_name: str) -> str: - return f"{self._base_url}/rest/wikis/xwiki/spaces/{self._space_key}/pages/{page_name}" + 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" + f"/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/ ← 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(f" 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}'") diff --git a/cli/main.py b/cli/main.py index 0542351..417c5f6 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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, diff --git a/pyproject.toml b/pyproject.toml index 0f0b5cf..6a5dfe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,11 @@ dev = [ [project.scripts] qms-kit = "cli.main:cli" +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/templates/capa_form.md.j2 b/templates/capa_form.md.j2 index 34369d6..5b5e805 100644 --- a/templates/capa_form.md.j2 +++ b/templates/capa_form.md.j2 @@ -1,7 +1,7 @@ # CAPA Form — Corrective Action -| | | -|---|---| +| Field | Value | +|-------|-------| | **CAPA ID** | {{ capa_id }} | | **Organisation** | {{ org_name }} | | **Opened** | {{ opened_date }} | @@ -16,7 +16,7 @@ **Problem Description:** -TODO: describe the problem +> **⚠ TODO:** describe the problem ## 2. Immediate Correction @@ -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 diff --git a/templates/capa_form.xwiki.j2 b/templates/capa_form.xwiki.j2 deleted file mode 100644 index 4e1c321..0000000 --- a/templates/capa_form.xwiki.j2 +++ /dev/null @@ -1,48 +0,0 @@ -= CAPA Form — Corrective Action = - -|=Field|=Value -|**CAPA ID**|{{ capa_id }} -|**Organisation**|{{ org_name }} -|**Opened**|{{ opened_date }} -|**Opened by**|{{ opened_by }} -|**ISO Clause**|10.2 - ----- - -== 1. Background == - -**Source (NC ID / Audit Finding / other trigger):** {{ source_ref }} - -**Problem Description:** **⚠ TODO:** describe the problem - -== 2. Immediate Correction == - -**Action:** TODO -**Completed by:** TODO -**Completed on:** TODO - -== 3. Root Cause Analysis == - -**Method:** ☐ 5-Why ☐ Ishikawa ☐ Other: TODO - -**Root Cause Identified:** **⚠ TODO:** describe root cause - -== 4. Corrective Action == - -|=Action|=Responsible|=Due Date|=Done -|TODO|TODO|TODO|☐ - -== 5. Effectiveness Review == - -**Review planned for:** TODO -**Review conducted on:** TODO -**Result:** ☐ Effective ☐ Not effective → new CAPA required - -== 6. Closure == - -**Closed on:** TODO -**Closed by:** TODO - ----- - -//ISO 9001:2015 · Clause 10.2 · {{ org_name }}// diff --git a/templates/internal_audit_checklist.md.j2 b/templates/internal_audit_checklist.md.j2 index 5de4b0f..c4ea19b 100644 --- a/templates/internal_audit_checklist.md.j2 +++ b/templates/internal_audit_checklist.md.j2 @@ -1,7 +1,7 @@ # Internal Audit — Checklist -| | | -|---|---| +| Field | Value | +|-------|-------| | **Organisation** | {{ org_name }} | | **Audit Date** | {{ audit_date }} | | **Auditor** | {{ auditor }} | @@ -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 --- diff --git a/templates/internal_audit_checklist.xwiki.j2 b/templates/internal_audit_checklist.xwiki.j2 deleted file mode 100644 index 979fb8c..0000000 --- a/templates/internal_audit_checklist.xwiki.j2 +++ /dev/null @@ -1,42 +0,0 @@ -= Internal Audit — Checklist = - -|=Field|=Value -|**Organisation**|{{ org_name }} -|**Audit Date**|{{ audit_date }} -|**Auditor**|{{ auditor }} -|**Audit Scope**|{{ audit_scope }} -|**Contact Person**|{{ contact }} - ----- - -== ISO 9001:2015 Checklist == - -|=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| - -== Audit Result == - -**Overall Assessment:** ☐ Conforming ☐ Conditionally conforming ☐ Non-conforming - -**Key Findings:** **⚠ TODO:** describe findings - -**Recommendations:** **⚠ TODO:** recommendations - ----- - -//ISO 9001:2015 · Clause 9.2 · {{ org_name }}// diff --git a/templates/mgmt_review_agenda.md.j2 b/templates/mgmt_review_agenda.md.j2 index 44835a5..bd6a5ea 100644 --- a/templates/mgmt_review_agenda.md.j2 +++ b/templates/mgmt_review_agenda.md.j2 @@ -1,7 +1,7 @@ # Management Review — Agenda and Minutes -| | | -|---|---| +| Field | Value | +|-------|-------| | **Organisation** | {{ org_name }} | | **Date** | {{ date }} | | **Attendees** | {{ participants }} | @@ -20,18 +20,18 @@ | 5 | Process performance & product conformity | Process reports | Process owners | | 6 | Nonconformities & CAPA status | NC/CAPA list | {{ quality_officer }} | | 7 | Internal audit results | Audit reports | {{ quality_officer }} | -| 8 | Resource needs | TODO: source | Management | +| 8 | Resource needs | **⚠ TODO:** source | Management | | 9 | Opportunities for improvement | All inputs | All | ## Decisions and Actions | No. | Decision / Action | Responsible | Due Date | |-----|-------------------|-------------|----------| -| 1 | TODO | TODO | TODO | +| 1 | **⚠ TODO** | TODO | TODO | ## Next Management Review -**Planned:** TODO: date +**Planned:** **⚠ TODO:** date --- diff --git a/templates/mgmt_review_agenda.xwiki.j2 b/templates/mgmt_review_agenda.xwiki.j2 deleted file mode 100644 index c0a748d..0000000 --- a/templates/mgmt_review_agenda.xwiki.j2 +++ /dev/null @@ -1,35 +0,0 @@ -= Management Review — Agenda and Minutes = - -|=Field|=Value -|**Organisation**|{{ org_name }} -|**Date**|{{ date }} -|**Attendees**|{{ participants }} -|**Facilitated by**|{{ quality_officer }} - ----- - -== Mandatory Agenda Items (ISO 9001:2015 Cl. 9.3.2) == - -|=No.|=Topic|=Input|=Responsible -|1|Status of actions from previous review|Previous MR minutes|{{ quality_officer }} -|2|Changes in external / internal context|Context analysis|Management -|3|Customer satisfaction & feedback|KPI dashboard|{{ quality_officer }} -|4|Achievement of quality objectives / KPIs|KPI dashboard|{{ quality_officer }} -|5|Process performance & product conformity|Process reports|Process owners -|6|Nonconformities & CAPA status|NC/CAPA list|{{ quality_officer }} -|7|Internal audit results|Audit reports|{{ quality_officer }} -|8|Resource needs|**⚠ TODO:** source|Management -|9|Opportunities for improvement|All inputs|All - -== Decisions and Actions == - -|=No.|=Decision / Action|=Responsible|=Due Date -|1|**⚠ TODO**|TODO|TODO - -== Next Management Review == - -**Planned:** **⚠ TODO:** date - ----- - -//ISO 9001:2015 · Clause 9.3 · {{ org_name }}// diff --git a/templates/policy.md.j2 b/templates/policy.md.j2 index 4ddb6a3..6e17876 100644 --- a/templates/policy.md.j2 +++ b/templates/policy.md.j2 @@ -1,9 +1,11 @@ # Quality Policy -**Organisation:** {{ org_name }} -**Date:** {{ date }} -**Version:** {{ version }} -**Approved by:** {{ management }} +| Field | Value | +|-------|-------| +| **Organisation** | {{ org_name }} | +| **Date** | {{ date }} | +| **Version** | {{ version }} | +| **Approved by** | {{ management }} | --- @@ -11,10 +13,9 @@ {{ org_name }} is committed to ... -> TODO: Insert quality policy statement. -> Describe what quality means to the organisation, what commitments management makes, -> and how customer needs are addressed. Aim for 3–5 concise sentences that reflect -> the company culture. +> **⚠ TODO:** Insert quality policy statement. Describe what quality means to the organisation, +> what commitments management makes, and how customer needs are addressed. +> Aim for 3–5 concise sentences that reflect the company culture. ## Quality Objectives diff --git a/templates/policy.xwiki.j2 b/templates/policy.xwiki.j2 deleted file mode 100644 index 5499dda..0000000 --- a/templates/policy.xwiki.j2 +++ /dev/null @@ -1,31 +0,0 @@ -= Quality Policy = - -|=Field|=Value -|**Organisation**|{{ org_name }} -|**Date**|{{ date }} -|**Version**|{{ version }} -|**Approved by**|{{ management }} - ----- - -== Our Quality Commitment == - -{{ org_name }} is committed to ... - -**⚠ TODO:** Insert quality policy statement. Describe what quality means to the organisation, -what commitments management makes, and how customer needs are addressed. -Aim for 3–5 concise sentences that reflect the company culture. - -== Quality Objectives == - -Specific quality objectives are defined in the objectives and action plan (Clause 6.2) -and reviewed regularly during the Management Review (Clause 9.3). - -== Communication == - -This Quality Policy is communicated to all employees and is available to -interested parties upon request. - ----- - -//ISO 9001:2015 · Clause 5.2 · {{ org_name }}// diff --git a/templates/procedure.md.j2 b/templates/procedure.md.j2 index 8a8eb83..75c6f57 100644 --- a/templates/procedure.md.j2 +++ b/templates/procedure.md.j2 @@ -1,7 +1,7 @@ # Procedure: {{ procedure_title }} -| | | -|---|---| +| Field | Value | +|-------|-------| | **Document ID** | {{ doc_id }} | | **Version** | {{ version }} | | **Date** | {{ date }} | @@ -13,30 +13,30 @@ ## 1. Purpose -> TODO: Describe the purpose of this procedure. +> **⚠ TODO:** Describe the purpose of this procedure. ## 2. Scope -> TODO: Who and what does this procedure apply to? +> **⚠ TODO:** Who and what does this procedure apply to? ## 3. Terms and Abbreviations | Term | Definition | |------|------------| -| TODO | TODO | +| TODO | TODO | ## 4. Process -> TODO: Describe the process steps. +> **⚠ TODO:** Describe the process steps. > Tip: export a draw.io process diagram as PNG and embed it here. | Step | Activity | Responsible | Document / Record | |------|----------|-------------|-------------------| -| 1 | TODO | TODO | TODO | +| 1 | TODO | TODO | TODO | ## 5. Related Documents -- TODO: Reference relevant forms, records, and other procedures +- **⚠ TODO:** Reference relevant forms, records, and other procedures ## 6. Change History diff --git a/templates/procedure.xwiki.j2 b/templates/procedure.xwiki.j2 deleted file mode 100644 index 3484163..0000000 --- a/templates/procedure.xwiki.j2 +++ /dev/null @@ -1,44 +0,0 @@ -= Procedure: {{ procedure_title }} = - -|=Field|=Value -|**Document ID**|{{ doc_id }} -|**Version**|{{ version }} -|**Date**|{{ date }} -|**Process Owner**|{{ owner_role }} -|**Approved by**|{{ approver }} -|**ISO Clause**|{{ clause }} - ----- - -== 1. Purpose == - -**⚠ TODO:** Describe the purpose of this procedure. - -== 2. Scope == - -**⚠ TODO:** Who and what does this procedure apply to? - -== 3. Terms and Abbreviations == - -|=Term|=Definition -|TODO|TODO - -== 4. Process == - -**⚠ TODO:** Describe the process steps. Tip: export a draw.io diagram as PNG and embed it here. - -|=Step|=Activity|=Responsible|=Document / Record -|1|TODO|TODO|TODO - -== 5. Related Documents == - -* **⚠ TODO:** Reference relevant forms, records, and other procedures - -== 6. Change History == - -|=Version|=Date|=Change|=Author -|1.0|{{ date }}|Initial release|{{ author }} - ----- - -//ISO 9001:2015 · Clause {{ clause }} · {{ org_name }}// diff --git a/templates/qms_index.md.j2 b/templates/qms_index.md.j2 new file mode 100644 index 0000000..ea055c4 --- /dev/null +++ b/templates/qms_index.md.j2 @@ -0,0 +1,33 @@ +# QM Manual — {{ org_name }} + +**Standard:** ISO 9001:2015 +**Quality Management Officer:** {{ quality_officer }} +**Version:** {{ version }} + +--- + +## Document Index + +| Clause | Document | Status | +|--------|----------|--------| +| 5.2 | [[Quality Policy>>QMS.quality_policy]] | ⚠ Draft | +| 7.5 | [[Control of Documented Information>>QMS.documented_info_procedure]] | ⚠ Draft | +| 8.2 | [[Determination of Customer Requirements>>QMS.customer_requirements_procedure]] | ⚠ Draft | +| 8.7 | [[Control of Nonconforming Outputs>>QMS.nonconformity_procedure]] | ⚠ Draft | +| 9.2 | [[Internal Audit>>QMS.internal_audit_procedure]] | ⚠ Draft | +| 9.3 | [[Management Review>>QMS.management_review_procedure]] | ⚠ Draft | +| 10.2 | [[Corrective Actions (CAPA)>>QMS.capa_procedure]] | ⚠ Draft | +| 10.3 | [[Continual Improvement>>QMS.continual_improvement_procedure]] | ⚠ Draft | + +## Record Types + +| Type | ISO Clause | Tracker | +|------|-----------|---------| +| Nonconformity (NC) | 8.7 / 10.2 | Redmine: Nonconformity | +| Corrective Action (CAPA) | 10.2 | Redmine: CAPA | +| Internal Audit | 9.2 | Redmine: Internal Audit | +| KPI Measurement | 9.1 | Redmine: KPI Measurement | + +--- + +*Generated by qms-kit · ISO 9001:2015 · {{ org_name }}* diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 926fc35..84e1e88 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -101,7 +101,8 @@ def test_deploy_upserts_all_pages(self) -> None: mock_resp.raise_for_status.return_value = None with patch("adapters.selfhosted.xwiki.requests.put", return_value=mock_resp) as mock_put: adapter.deploy(pages) - assert mock_put.call_count == 2 + # 1 parent page + 2 document pages = 3 PUT calls + assert mock_put.call_count == 3 def test_base_url_trailing_slash_stripped(self) -> None: adapter = XWikiAdapter(_make_config(xwiki_url="http://xwiki.test/")) diff --git a/tests/test_md_to_xwiki.py b/tests/test_md_to_xwiki.py new file mode 100644 index 0000000..75163d9 --- /dev/null +++ b/tests/test_md_to_xwiki.py @@ -0,0 +1,72 @@ +"""Unit tests for the Markdown → XWiki 2.1 converter.""" + +from adapters.selfhosted.xwiki import md_to_xwiki + + +class TestHeadings: + def test_h1(self) -> None: + assert md_to_xwiki("# Title") == "= Title =" + + def test_h2(self) -> None: + assert md_to_xwiki("## Section") == "== Section ==" + + def test_h3(self) -> None: + assert md_to_xwiki("### Sub") == "=== Sub ===" + + +class TestHorizontalRule: + def test_dashes(self) -> None: + assert md_to_xwiki("---") == "----" + + def test_long_dashes(self) -> None: + assert md_to_xwiki("-------") == "----" + + +class TestInline: + def test_bold_preserved(self) -> None: + assert "**bold**" in md_to_xwiki("**bold** text") + + def test_italic_asterisk(self) -> None: + assert "//italic//" in md_to_xwiki("*italic* text") + + def test_italic_underscore(self) -> None: + assert "//italic//" in md_to_xwiki("_italic_ text") + + def test_markdown_link(self) -> None: + result = md_to_xwiki("[XWiki](https://xwiki.org)") + assert "[[XWiki>>https://xwiki.org]]" in result + + +class TestTables: + def test_header_row_uses_equals(self) -> None: + table = "| Col A | Col B |\n|-------|-------|\n| a | b |" + result = md_to_xwiki(table) + assert "|=Col A|=Col B" in result + + def test_data_row_no_equals(self) -> None: + table = "| Col A | Col B |\n|-------|-------|\n| a | b |" + result = md_to_xwiki(table) + assert "|a|b" in result + + def test_separator_row_skipped(self) -> None: + table = "| A |\n|---|\n| b |" + result = md_to_xwiki(table) + assert "---" not in result + + +class TestLists: + def test_unordered_dash(self) -> None: + assert md_to_xwiki("- item") == "* item" + + def test_unordered_asterisk(self) -> None: + assert md_to_xwiki("* item") == "* item" + + def test_ordered(self) -> None: + assert md_to_xwiki("1. first") == "1. first" + + +class TestBlockquote: + def test_blockquote_stripped(self) -> None: + result = md_to_xwiki("> some note") + assert "some note" in result + assert ">" not in result diff --git a/tests/test_renderer.py b/tests/test_renderer.py index 8ea6027..26598f2 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -38,7 +38,7 @@ def test_extra_overrides_base(self) -> None: class TestRenderTemplate: def test_policy_renders_org_name(self) -> None: result = render_template( - "policy.xwiki.j2", + "policy.md.j2", _make_config(), TEMPLATES_DIR, extra={"date": "2024-01-01"}, @@ -48,7 +48,7 @@ def test_policy_renders_org_name(self) -> None: def test_procedure_renders_title(self) -> None: result = render_template( - "procedure.xwiki.j2", + "procedure.md.j2", _make_config(), TEMPLATES_DIR, extra={ @@ -66,7 +66,7 @@ def test_procedure_renders_title(self) -> None: def test_capa_form_renders(self) -> None: result = render_template( - "capa_form.xwiki.j2", + "capa_form.md.j2", _make_config(), TEMPLATES_DIR, extra={ diff --git a/uv.lock b/uv.lock index f434f7c..d6f061b 100644 --- a/uv.lock +++ b/uv.lock @@ -712,6 +712,11 @@ dev = [ { name = "types-requests" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.8" }, @@ -729,6 +734,9 @@ requires-dist = [ ] provides-extras = ["dev"] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "requests" version = "2.34.2" From 17b187f2cf1a0ea1801aca2baf5ab1b5bd2432bd Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Sat, 4 Jul 2026 21:26:25 +0200 Subject: [PATCH 2/3] fix(lint): remove f-string without placeholder (F541) --- adapters/selfhosted/xwiki.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adapters/selfhosted/xwiki.py b/adapters/selfhosted/xwiki.py index 21c19af..f39439e 100644 --- a/adapters/selfhosted/xwiki.py +++ b/adapters/selfhosted/xwiki.py @@ -179,7 +179,7 @@ def deploy(self, rendered_pages: dict[str, tuple[str, str]]) -> None: if index_entry: title, content = index_entry self.create_or_update_page("WebHome", title, content) - print(f" XWiki: upserted space index 'WebHome'") + print(" XWiki: upserted space index 'WebHome'") # Parent page: "QM Manual" at space root level parent_name = self._parent_page.replace(" ", "_") From 011b232524c0433bf1cfb82272e7c4a6a4d7a67c Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Sat, 4 Jul 2026 21:29:21 +0200 Subject: [PATCH 3/3] fix(lint): ruff format xwiki.py, add ruff as dev dependency --- adapters/selfhosted/xwiki.py | 10 ++-------- pyproject.toml | 1 + uv.lock | 31 ++++++++++++++++++++++++++++++- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/adapters/selfhosted/xwiki.py b/adapters/selfhosted/xwiki.py index f39439e..d60119e 100644 --- a/adapters/selfhosted/xwiki.py +++ b/adapters/selfhosted/xwiki.py @@ -53,10 +53,7 @@ def md_to_xwiki(text: str) -> str: 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]) - ) + 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: @@ -138,10 +135,7 @@ def _page_url(self, page_name: str, parent: str | None = None) -> str: 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" - f"/spaces/{self._space_key}/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) diff --git a/pyproject.toml b/pyproject.toml index 6a5dfe0..45d66d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,6 +38,7 @@ qms-kit = "cli.main:cli" [dependency-groups] dev = [ "pytest>=9.1.1", + "ruff>=0.15.20", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index d6f061b..8305b01 100644 --- a/uv.lock +++ b/uv.lock @@ -715,6 +715,7 @@ dev = [ [package.dev-dependencies] dev = [ { name = "pytest" }, + { name = "ruff" }, ] [package.metadata] @@ -735,7 +736,10 @@ requires-dist = [ provides-extras = ["dev"] [package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=9.1.1" }] +dev = [ + { name = "pytest", specifier = ">=9.1.1" }, + { name = "ruff", specifier = ">=0.15.20" }, +] [[package]] name = "requests" @@ -765,6 +769,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + [[package]] name = "stevedore" version = "5.9.0"