From b86c3c4388dc5a69dca14d3f6ba74b0c1badc8d6 Mon Sep 17 00:00:00 2001 From: Gerald Fruhmann Date: Sat, 4 Jul 2026 20:05:43 +0200 Subject: [PATCH] feat(config): add SelfhostedConfig schema and wire adapters to config - Add XWikiConfig, RedmineConfig, SelfhostedConfig Pydantic models - CoreConfig gains optional target + selfhosted fields - selfhosted.yaml restructured under selfhosted: key to match schema - XWikiAdapter and RedmineAdapter now read all settings from CoreConfig - CLI _deploy_selfhosted reads tracker_mapping from config (no hardcoded values) - Add cli/__init__.py for clean package structure - Add config/clients/example.yaml as reference template for client overlays - Add 3 tests covering selfhosted overlay loading and tracker mapping validation Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + adapters/selfhosted/redmine.py | 10 +++++---- adapters/selfhosted/xwiki.py | 20 ++++++++++------- cli/main.py | 18 +++++++--------- config/clients/example.yaml | 33 ++++++++++++++++++++++++++++ config/selfhosted.yaml | 39 +++++++++++++++++----------------- src/core/config.py | 20 +++++++++++++++++ tests/test_config.py | 23 +++++++++++++++++++- 8 files changed, 122 insertions(+), 42 deletions(-) create mode 100644 config/clients/example.yaml diff --git a/.gitignore b/.gitignore index 4a2b725..3f7d27b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ build/ # Secrets & client configs — track directory structure but not actual client files config/clients/* !config/clients/.gitkeep +!config/clients/example.yaml # OS .DS_Store diff --git a/adapters/selfhosted/redmine.py b/adapters/selfhosted/redmine.py index a0a0e82..4402566 100644 --- a/adapters/selfhosted/redmine.py +++ b/adapters/selfhosted/redmine.py @@ -23,10 +23,12 @@ class RedmineAdapter: """ def __init__(self, config: CoreConfig) -> None: - # TODO: extract Redmine settings from overlay config once overlay schema is wired - self._base_url = "http://localhost:3000" + if config.selfhosted is None: + raise ValueError("selfhosted config block is required for target=selfhosted") + redmine = config.selfhosted.redmine + self._base_url = redmine.base_url.rstrip("/") + self._project_key = redmine.project_key self._api_key = os.environ.get("REDMINE_API_KEY", "") - self._project_key = "qms" self._config = config @property @@ -38,7 +40,7 @@ def _get(self, path: str) -> Any: resp.raise_for_status() return resp.json() - def _post(self, path: str, payload: dict) -> Any: + def _post(self, path: str, payload: dict[str, Any]) -> Any: resp = requests.post( f"{self._base_url}{path}", json=payload, headers=self._headers, timeout=30 ) diff --git a/adapters/selfhosted/xwiki.py b/adapters/selfhosted/xwiki.py index afe63f4..eebd4d3 100644 --- a/adapters/selfhosted/xwiki.py +++ b/adapters/selfhosted/xwiki.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from typing import Any import requests @@ -17,12 +18,13 @@ class XWikiAdapter: """ def __init__(self, config: CoreConfig) -> None: - # TODO: extract XWiki settings from overlay config once overlay schema is wired - self._base_url = "http://localhost:8080" - self._space_key = "QMS" - self._username = "Admin" + if config.selfhosted is None: + raise ValueError("selfhosted config block is required for target=selfhosted") + xwiki = config.selfhosted.xwiki + self._base_url = xwiki.base_url.rstrip("/") + self._space_key = xwiki.space_key + self._username = xwiki.username self._password = os.environ.get("XWIKI_PASSWORD", "") - self._config = config @property def _auth(self) -> tuple[str, str]: @@ -31,16 +33,18 @@ def _auth(self) -> tuple[str, str]: def _page_url(self, page_name: str) -> str: 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) 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.""" - url = self._page_url(page_name) payload = {"title": title, "content": content, "syntax": "markdown/1.2"} - resp = requests.put(url, json=payload, auth=self._auth, timeout=30) - resp.raise_for_status() + self._put(self._page_url(page_name), payload) def deploy(self, rendered_pages: dict[str, tuple[str, str]]) -> None: """Deploy all rendered pages. diff --git a/cli/main.py b/cli/main.py index 8a1720c..77c1d41 100644 --- a/cli/main.py +++ b/cli/main.py @@ -87,20 +87,18 @@ def deploy(target: str, config_path: Path, dry_run: bool) -> None: def _deploy_selfhosted(config: object, rendered: dict[str, tuple[str, str]]) -> None: from adapters.selfhosted.redmine import RedmineAdapter from adapters.selfhosted.xwiki import XWikiAdapter + from src.core.config import CoreConfig + + cfg = config if isinstance(config, CoreConfig) else None + if cfg is None or cfg.selfhosted is None: + raise click.ClickException("selfhosted config block missing in overlay.") click.echo("\nDeploying to self-hosted (XWiki + Redmine)...") - xwiki = XWikiAdapter(config) # type: ignore[arg-type] + xwiki = XWikiAdapter(cfg) xwiki.deploy(rendered) - # TODO: load tracker_mapping from overlay config - tracker_mapping = { - "nc": "Nonconformity", - "capa": "CAPA", - "audit": "Internal Audit", - "kpi": "KPI Measurement", - } - redmine = RedmineAdapter(config) # type: ignore[arg-type] - redmine.deploy(tracker_mapping) + redmine = RedmineAdapter(cfg) + redmine.deploy(cfg.selfhosted.redmine.tracker_mapping) click.echo("\nDone.") diff --git a/config/clients/example.yaml b/config/clients/example.yaml new file mode 100644 index 0000000..156225c --- /dev/null +++ b/config/clients/example.yaml @@ -0,0 +1,33 @@ +# Example client overlay — copy and fill in per client. +# This file is safe to commit (no secrets). Passwords/keys go in env vars. +# +# Usage: +# cp config/clients/example.yaml config/clients/acme.yaml +# # edit acme.yaml, then: +# qms-kit deploy --target selfhosted --config config/clients/acme.yaml + +target: selfhosted + +organisation: + name: "Acme GmbH" + short: "ACM" + quality_officer: "Jane Smith" + management: "John Doe" + +selfhosted: + xwiki: + base_url: "https://wiki.acme.example" + space_key: "QMS" + username: "admin" + # export XWIKI_PASSWORD= + parent_page: "QM Manual" + + redmine: + base_url: "https://redmine.acme.example" + project_key: "qms-acme" + # export REDMINE_API_KEY= + tracker_mapping: + nc: "Nonconformity" + capa: "CAPA" + audit: "Internal Audit" + kpi: "KPI Measurement" diff --git a/config/selfhosted.yaml b/config/selfhosted.yaml index 739b6ef..19167ea 100644 --- a/config/selfhosted.yaml +++ b/config/selfhosted.yaml @@ -9,23 +9,24 @@ organisation: # overrides core.yaml placeholders for this client quality_officer: "TODO: First Last" management: "TODO: First Last" -xwiki: - base_url: "http://localhost:8080" # TODO: production URL - space_key: "QMS" - username: "TODO" - # password via env: XWIKI_PASSWORD - parent_page: "QM Manual" - # [Not verified] Approval/publication workflow may require XWiki Extension. - # TODO: verify extension availability and configure approval_extension here. +selfhosted: + xwiki: + base_url: "http://localhost:8080" # TODO: production URL + space_key: "QMS" + username: "TODO" + # password via env: XWIKI_PASSWORD + parent_page: "QM Manual" + # [Not verified] Approval/publication workflow may require XWiki Extension. + # TODO: verify extension availability and configure approval_extension here. -redmine: - base_url: "http://localhost:3000" # TODO: production URL - project_key: "qms" - # api_key via env: REDMINE_API_KEY - # IMPORTANT (verified): custom field DEFINITIONS cannot be created via API. - # Run manual setup once before deploying. See docs/redmine-setup.md. - tracker_mapping: - nc: "Nonconformity" # must match tracker name in Redmine - capa: "CAPA" - audit: "Internal Audit" - kpi: "KPI Measurement" + redmine: + base_url: "http://localhost:3000" # TODO: production URL + project_key: "qms" + # api_key via env: REDMINE_API_KEY + # IMPORTANT (verified): custom field DEFINITIONS cannot be created via API. + # Run manual setup once before deploying. See docs/redmine-setup.md. + tracker_mapping: + nc: "Nonconformity" # must match tracker name in Redmine + capa: "CAPA" + audit: "Internal Audit" + kpi: "KPI Measurement" diff --git a/src/core/config.py b/src/core/config.py index 18317f3..384bd85 100644 --- a/src/core/config.py +++ b/src/core/config.py @@ -66,6 +66,24 @@ class Meta(BaseModel): standard: str +class XWikiConfig(BaseModel): + base_url: str + space_key: str + username: str + parent_page: str = "QM Manual" + + +class RedmineConfig(BaseModel): + base_url: str + project_key: str + tracker_mapping: dict[str, str] + + +class SelfhostedConfig(BaseModel): + xwiki: XWikiConfig + redmine: RedmineConfig + + class CoreConfig(BaseModel): meta: Meta organisation: Organisation @@ -75,6 +93,8 @@ class CoreConfig(BaseModel): record_types: list[RecordType] kpis: list[KPI] capa_states: list[CapaState] + target: str | None = None + selfhosted: SelfhostedConfig | None = None @field_validator("documents") @classmethod diff --git a/tests/test_config.py b/tests/test_config.py index e6db3c7..0817286 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,7 +2,7 @@ from pathlib import Path -from src.core.config import _deep_merge +from src.core.config import _deep_merge, load_config CORE_YAML = Path(__file__).parent.parent / "config" / "core.yaml" SELFHOSTED_YAML = Path(__file__).parent.parent / "config" / "selfhosted.yaml" @@ -63,3 +63,24 @@ def test_capa_state_transitions_reference_valid_states(self) -> None: assert transition in state_ids, ( f"State '{state['id']}' transitions to unknown state '{transition}'" ) + + +class TestSelfhostedConfig: + def test_selfhosted_overlay_loads_and_validates(self) -> None: + cfg = load_config(CORE_YAML, SELFHOSTED_YAML) + assert cfg.target == "selfhosted" + assert cfg.selfhosted is not None + assert cfg.selfhosted.xwiki.space_key == "QMS" + assert cfg.selfhosted.redmine.project_key == "qms" + + def test_tracker_mapping_has_all_record_types(self) -> None: + cfg = load_config(CORE_YAML, SELFHOSTED_YAML) + assert cfg.selfhosted is not None + mapping = cfg.selfhosted.redmine.tracker_mapping + record_ids = {r.id for r in cfg.record_types} + for record_id in record_ids: + assert record_id in mapping, f"No tracker mapping for record type '{record_id}'" + + def test_missing_selfhosted_block_gives_none(self) -> None: + cfg = load_config(CORE_YAML, CORE_YAML) + assert cfg.selfhosted is None