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
Empty file added adapters/__init__.py
Empty file.
Empty file.
Empty file added adapters/m365/__init__.py
Empty file.
Empty file.
Empty file added src/core/__init__.py
Empty file.
115 changes: 115 additions & 0 deletions src/core/config.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions src/core/renderer.py
Original file line number Diff line number Diff line change
@@ -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)
65 changes: 65 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -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}'"
)
80 changes: 80 additions & 0 deletions tests/test_renderer.py
Original file line number Diff line number Diff line change
@@ -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