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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions adapters/selfhosted/redmine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
Expand Down
20 changes: 12 additions & 8 deletions adapters/selfhosted/xwiki.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import os
from typing import Any

import requests

Expand All @@ -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]:
Expand All @@ -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.
Expand Down
18 changes: 8 additions & 10 deletions cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
33 changes: 33 additions & 0 deletions config/clients/example.yaml
Original file line number Diff line number Diff line change
@@ -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=<password>
parent_page: "QM Manual"

redmine:
base_url: "https://redmine.acme.example"
project_key: "qms-acme"
# export REDMINE_API_KEY=<api-key>
tracker_mapping:
nc: "Nonconformity"
capa: "CAPA"
audit: "Internal Audit"
kpi: "KPI Measurement"
39 changes: 20 additions & 19 deletions config/selfhosted.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
20 changes: 20 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
23 changes: 22 additions & 1 deletion tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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