diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/atlassian/__init__.py b/adapters/atlassian/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/m365/__init__.py b/adapters/m365/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/adapters/selfhosted/__init__.py b/adapters/selfhosted/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/config.py b/src/core/config.py new file mode 100644 index 0000000..18317f3 --- /dev/null +++ b/src/core/config.py @@ -0,0 +1,115 @@ +"""Config loader — merges core.yaml with a target overlay and validates with Pydantic.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml +from pydantic import BaseModel, field_validator + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + + +class Organisation(BaseModel): + name: str + short: str + quality_officer: str + management: str + + +class Role(BaseModel): + id: str + label: str + responsibilities: list[str] + + +class Clause(BaseModel): + id: str + title: str + documents: list[str] + + +class Document(BaseModel): + id: str + title: str + template: str | None + clause: str + + +class RecordType(BaseModel): + id: str + label: str + clause: str + fields: list[str] + + +class KPI(BaseModel): + id: str + label: str + unit: str + target: str + frequency: str + clause: str + + +class CapaState(BaseModel): + id: str + label: str + transitions: list[str] + + +class Meta(BaseModel): + version: str + standard: str + + +class CoreConfig(BaseModel): + meta: Meta + organisation: Organisation + roles: list[Role] + clauses: list[Clause] + documents: list[Document] + record_types: list[RecordType] + kpis: list[KPI] + capa_states: list[CapaState] + + @field_validator("documents") + @classmethod + def document_ids_unique(cls, docs: list[Document]) -> list[Document]: + ids = [d.id for d in docs] + duplicates = {i for i in ids if ids.count(i) > 1} + if duplicates: + raise ValueError(f"Duplicate document IDs: {duplicates}") + return docs + + +# --------------------------------------------------------------------------- +# Loader +# --------------------------------------------------------------------------- + + +def _load_yaml(path: Path) -> dict[str, Any]: + with path.open(encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def _deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: + """Recursively merge overlay into base (overlay wins on conflicts).""" + merged = base.copy() + for key, value in overlay.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def load_config(core_path: Path, overlay_path: Path) -> CoreConfig: + """Load and merge core + overlay YAML, return validated CoreConfig.""" + core_data = _load_yaml(core_path) + overlay_data = _load_yaml(overlay_path) + merged = _deep_merge(core_data, overlay_data) + return CoreConfig.model_validate(merged) diff --git a/src/core/renderer.py b/src/core/renderer.py new file mode 100644 index 0000000..fb4a4cc --- /dev/null +++ b/src/core/renderer.py @@ -0,0 +1,44 @@ +"""Jinja2 template renderer — renders .md.j2 templates with config context.""" + +from __future__ import annotations + +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape + +from src.core.config import CoreConfig + + +def build_context(config: CoreConfig, extra: dict | None = None) -> dict: + """Build the base Jinja2 context from a validated config.""" + ctx: dict = { + "org_name": config.organisation.name, + "org_short": config.organisation.short, + "quality_officer": config.organisation.quality_officer, + "management": config.organisation.management, + "version": config.meta.version, + } + if extra: + ctx.update(extra) + return ctx + + +def render_template( + template_name: str, + config: CoreConfig, + templates_dir: Path, + extra: dict | None = None, +) -> str: + """Render a single .md.j2 template and return the result as a string. + + Raises jinja2.UndefinedError if a placeholder has no value in context. + """ + env = Environment( + loader=FileSystemLoader(str(templates_dir)), + undefined=StrictUndefined, + autoescape=select_autoescape(enabled_extensions=("html",)), + keep_trailing_newline=True, + ) + template = env.get_template(template_name) + context = build_context(config, extra) + return template.render(**context) diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..e6db3c7 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,65 @@ +"""Unit tests for the config loader.""" + +from pathlib import Path + +from src.core.config import _deep_merge + +CORE_YAML = Path(__file__).parent.parent / "config" / "core.yaml" +SELFHOSTED_YAML = Path(__file__).parent.parent / "config" / "selfhosted.yaml" + + +class TestDeepMerge: + def test_overlay_wins_on_conflict(self) -> None: + base = {"a": 1, "b": {"x": 10, "y": 20}} + overlay = {"b": {"x": 99}, "c": 3} + result = _deep_merge(base, overlay) + assert result == {"a": 1, "b": {"x": 99, "y": 20}, "c": 3} + + def test_base_unchanged(self) -> None: + base = {"a": 1} + _deep_merge(base, {"a": 2}) + assert base == {"a": 1} + + +class TestCoreConfig: + def test_core_yaml_loads_without_overlay(self) -> None: + """core.yaml must be parseable on its own (Jinja placeholders are strings).""" + import yaml + + with CORE_YAML.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) + assert "clauses" in data + assert "documents" in data + assert len(data["clauses"]) == 7 # clauses 4-10 + + def test_all_clause_document_refs_exist(self) -> None: + import yaml + + with CORE_YAML.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) + doc_ids = {d["id"] for d in data["documents"]} + for clause in data["clauses"]: + for ref in clause["documents"]: + assert ref in doc_ids, f"Clause {clause['id']} references unknown doc '{ref}'" + + def test_kpis_have_required_fields(self) -> None: + import yaml + + with CORE_YAML.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) + for kpi in data["kpis"]: + assert "id" in kpi + assert "label" in kpi + assert "target" in kpi + + def test_capa_state_transitions_reference_valid_states(self) -> None: + import yaml + + with CORE_YAML.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) + state_ids = {s["id"] for s in data["capa_states"]} + for state in data["capa_states"]: + for transition in state["transitions"]: + assert transition in state_ids, ( + f"State '{state['id']}' transitions to unknown state '{transition}'" + ) diff --git a/tests/test_renderer.py b/tests/test_renderer.py new file mode 100644 index 0000000..26598f2 --- /dev/null +++ b/tests/test_renderer.py @@ -0,0 +1,80 @@ +"""Unit tests for the Jinja2 template renderer.""" + +from pathlib import Path +from unittest.mock import MagicMock + +from src.core.renderer import build_context, render_template + +TEMPLATES_DIR = Path(__file__).parent.parent / "templates" + + +def _make_config(org_name: str = "Acme Ltd") -> MagicMock: + cfg = MagicMock() + cfg.organisation.name = org_name + cfg.organisation.short = "ACME" + cfg.organisation.quality_officer = "Jane Smith" + cfg.organisation.management = "John Doe" + cfg.meta.version = "1.0" + return cfg + + +class TestBuildContext: + def test_contains_org_fields(self) -> None: + ctx = build_context(_make_config()) + assert ctx["org_name"] == "Acme Ltd" + assert ctx["quality_officer"] == "Jane Smith" + assert ctx["management"] == "John Doe" + assert ctx["version"] == "1.0" + + def test_extra_values_merged(self) -> None: + ctx = build_context(_make_config(), extra={"date": "2024-01-01"}) + assert ctx["date"] == "2024-01-01" + + def test_extra_overrides_base(self) -> None: + ctx = build_context(_make_config(), extra={"org_name": "Override Corp"}) + assert ctx["org_name"] == "Override Corp" + + +class TestRenderTemplate: + def test_policy_renders_org_name(self) -> None: + result = render_template( + "policy.md.j2", + _make_config(), + TEMPLATES_DIR, + extra={"date": "2024-01-01"}, + ) + assert "Acme Ltd" in result + assert "ISO 9001:2015" in result + + def test_procedure_renders_title(self) -> None: + result = render_template( + "procedure.md.j2", + _make_config(), + TEMPLATES_DIR, + extra={ + "procedure_title": "Document Control", + "doc_id": "QMS-7.5-001", + "clause": "7.5", + "owner_role": "QMO", + "approver": "John Doe", + "author": "Jane Smith", + "date": "2024-01-01", + }, + ) + assert "Document Control" in result + assert "7.5" in result + + def test_capa_form_renders(self) -> None: + result = render_template( + "capa_form.md.j2", + _make_config(), + TEMPLATES_DIR, + extra={ + "capa_id": "CAPA-2024-001", + "opened_date": "2024-01-01", + "opened_by": "Jane Smith", + "source_ref": "NC-2024-003", + }, + ) + assert "CAPA-2024-001" in result + assert "10.2" in result