Skip to content
Closed
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,7 @@ dist/
htmlcov/
coverage.xml
coverage.json
phu_templates/* __pycache__/
phu_templates/**/*.pyc
phu_templates/*template*
phu_templates/assets/
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,44 @@ uv run viper students.xlsx en
uv run viper students.xlsx en --output-dir /tmp/output
```

## Running modes (test vs production)

This project supports a "test mode" (the default) and a production/PHU-specific mode.

- Test mode (default): uses the shared templates in `templates/` and assets in `templates/assets`.
- Production (PHU) mode: uses PHU-specific templates in `phu_templates/` and assets in `phu_templates/assets`.

Control mode with the CLI flag:

```bash
# test mode (default)
python -m pipeline.orchestrator students.xlsx en

# explicitly enable test mode (same as default)
python -m pipeline.orchestrator students.xlsx en --test-mode

# production / PHU mode (uses phu_templates)
python -m pipeline.orchestrator students.xlsx en --prod
```

If you use the project entrypoint (`uv run viper`) the flags are the same:

```bash
uv run viper students.xlsx en --prod
```

Notes about import path and runners
- Run from the repository root so Python can import the top-level packages (`templates`, `phu_templates`).
- If your runner changes working directories (some wrappers do), make the project importable by either installing the package in editable mode or setting PYTHONPATH:

```bash

# or set PYTHONPATH for a single run
PYTHONPATH=/home/jovyan/immunization-charts-python uv run viper students.xlsx en --prod
```

Why this matters: the orchestrator and generator import template packages by name (e.g. `phu_templates.en_template_row`). If the repository root is not on `sys.path`, Python cannot find `phu_templates` and the pipeline will fail with "No module named 'phu_templates'". Installing editable or setting PYTHONPATH avoids that issue.

> ℹ️ **Typst preview note:** The WDGPH code-server development environments render Typst files via Tinymist. The shared template at `templates/conf.typ` only defines helper functions, colour tokens, and table layouts that the generated notice `.typ` files import; it doesn't emit any pages on its own, so Tinymist has nothing to preview if attempted on this file. To examine the actual markup that uses these helpers, run the pipeline with `pipeline.keep_intermediate_files: true` in `config/parameters.yaml` so the generated notice `.typ` files stay in `output/artifacts/` for manual inspection.

**Outputs:**
Expand Down
Empty file added phu_templates/__init__.py
Empty file.
77 changes: 67 additions & 10 deletions pipeline/generate_notices.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,10 @@
from .translation_helpers import display_label
from .utils import deserialize_client_record

from templates.en_template import render_notice as render_notice_en
from templates.fr_template import render_notice as render_notice_fr
#from phu_templates.fr_template import render_notice as render_notice_phu_fr
#from phu_templates.en_template_row import render_notice as render_notice_phu_en
import importlib
# importlib_util intentionally not needed here

SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent
Expand All @@ -65,13 +67,45 @@


# Build renderer dict from Language enum
_LANGUAGE_RENDERERS = {
Language.ENGLISH.value: render_notice_en,
Language.FRENCH.value: render_notice_fr,
# NOTE: Templates are imported dynamically at runtime so callers can choose
# which template package to use (shared `templates` for test mode, or
# `phu_templates` for production PHU-specific templates). This avoids
# hard imports at module import time which would require both packages to
# be present.

TEMPLATE_MODULE_MAP = {
# shared test templates package
"templates": {
Language.ENGLISH.value: "en_template",
Language.FRENCH.value: "fr_template",
},
# PHU-specific templates package (production)
"phu_templates": {
# PHU repo uses a different module name for English templates
Language.ENGLISH.value: "en_template_row",
# If a PHU-specific French template exists name it here, otherwise
# the code will fall back to the shared `templates` package.
Language.FRENCH.value: "fr_template",
},
Comment on lines +76 to +89
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should perhaps be in config if needed

}


def get_language_renderer(language: Language):
def _import_renderer(module_base: str, module_name: str):
"""Import a renderer function from a module if available.

Returns the `render_notice` callable from the resolved module.
Raises ImportError if the module cannot be imported or does not expose
`render_notice`.
"""
full_name = f"{module_base}.{module_name}"
mod = importlib.import_module(full_name)
print(mod)
if not hasattr(mod, "render_notice"):
raise ImportError(f"Module {full_name} does not provide `render_notice`")
return getattr(mod, "render_notice")


def get_language_renderer(language: Language, templates_package: str):
"""Get template renderer for given language.

Maps Language enum values to their corresponding template rendering functions.
Expand All @@ -96,9 +130,27 @@ def get_language_renderer(language: Language):
>>> renderer = get_language_renderer(Language.ENGLISH)
>>> # renderer is now render_notice_en function
"""
# Language is already validated upstream (CLI choices + Language.from_string())
# Direct lookup; safe because only valid Language enums reach this function
return _LANGUAGE_RENDERERS[language.value]
# Attempt to import the renderer from the requested templates package.
# Resolve module name by package-specific map, falling back to the shared
# `templates` mapping if the requested package doesn't provide an entry.
pkg_map = TEMPLATE_MODULE_MAP.get(templates_package)
if pkg_map and language.value in pkg_map:
module_name = pkg_map[language.value]
else:
# fallback to shared templates mapping
module_name = TEMPLATE_MODULE_MAP["templates"].get(language.value)

if not module_name:
raise KeyError(f"Unsupported language for templates: {language}")

# Try to import from the requested package first, then fall back to the
# shared `templates` package if needed.
try:
return _import_renderer(templates_package, module_name)
except Exception as exc: # pragma: no cover - bubble up original import error
raise ImportError(
f"Could not import renderer for language {language.value} from {templates_package} or fallback templates: {exc}"
) from exc


def read_artifact(path: Path) -> ArtifactPayload:
Expand Down Expand Up @@ -402,9 +454,10 @@ def render_notice(
logo: Path,
signature: Path,
qr_output_dir: Path | None = None,
templates_package: str = "templates",
) -> str:
language = Language.from_string(client.language)
renderer = get_language_renderer(language)
renderer = get_language_renderer(language, templates_package)
context = build_template_context(client, qr_output_dir)
return renderer(
context,
Expand All @@ -418,6 +471,7 @@ def generate_typst_files(
output_dir: Path,
logo_path: Path,
signature_path: Path,
templates_package: str = "templates",
) -> List[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
qr_output_dir = output_dir / "qr_codes"
Expand All @@ -436,6 +490,7 @@ def generate_typst_files(
logo=logo_path,
signature=signature_path,
qr_output_dir=qr_output_dir,
templates_package=templates_package,
)
filename = f"{language}_notice_{client.sequence}_{client.client_id}.typ"
file_path = typst_output_dir / filename
Expand All @@ -450,6 +505,7 @@ def main(
output_dir: Path,
logo_path: Path,
signature_path: Path,
templates_package: str = "templates",
) -> List[Path]:
"""Main entry point for Typst notice generation.

Expand All @@ -475,6 +531,7 @@ def main(
output_dir,
logo_path,
signature_path,
templates_package=templates_package,
)
print(
f"Generated {len(generated)} Typst files in {output_dir} for language {payload.language}"
Expand Down
37 changes: 35 additions & 2 deletions pipeline/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@
ROOT_DIR = SCRIPT_DIR.parent
DEFAULT_INPUT_DIR = ROOT_DIR / "input"
DEFAULT_OUTPUT_DIR = ROOT_DIR / "output"
DEFAULT_TEMPLATES_ASSETS_DIR = ROOT_DIR / "templates" / "assets"
PHU_TEMPLATES_ASSETS_DIR = ROOT_DIR / "phu_templates" / "assets"
DEFAULT_TEST_TEMPLATES_ASSETS_DIR = ROOT_DIR / "templates" / "assets"
DEFAULT_CONFIG_DIR = ROOT_DIR / "config"


Expand Down Expand Up @@ -100,6 +101,23 @@ def parse_args() -> argparse.Namespace:
default=DEFAULT_CONFIG_DIR,
help=f"Config directory (default: {DEFAULT_CONFIG_DIR})",
)
# Test mode is enabled by default. Use --prod to disable.
parser.add_argument(
"--test-mode",
dest="test_mode",
action="store_true",
help=(
"Enable test mode with verbose logging and additional checks. "
"(Default: enabled)"
),
)
parser.add_argument(
"--prod",
dest="test_mode",
action="store_false",
help="Disable test mode and use production templates/assets.",
)
parser.set_defaults(test_mode=True)

return parser.parse_args()

Expand Down Expand Up @@ -250,6 +268,7 @@ def run_step_4_generate_notices(
run_id: str,
assets_dir: Path,
config_dir: Path,
templates_package,
) -> None:
"""Step 4: Generating Typst templates."""
print_step(4, "Generating Typst templates")
Expand All @@ -265,6 +284,7 @@ def run_step_4_generate_notices(
artifacts_dir,
logo_path,
signature_path,
templates_package=templates_package,
)
print(f"Generated {len(generated)} Typst files in {artifacts_dir}")

Expand Down Expand Up @@ -466,6 +486,18 @@ def main() -> int:

print_header(args.input_file)

# Choose templates/assets directory based on test mode setting.
# Test mode is enabled by default and uses the shared `templates/assets` directory.
# Production mode (when --prod) uses PHU-specific templates in `phu_templates/assets`.
assets_dir = (
DEFAULT_TEST_TEMPLATES_ASSETS_DIR if getattr(args, "test_mode", True) else PHU_TEMPLATES_ASSETS_DIR
)
print(f"Using templates/assets directory: {assets_dir}")
# Map test_mode to templates package name used by generate_notices
templates_pkg = "templates" if getattr(args, "test_mode", True) else "phu_templates"
print(templates_pkg)
print(getattr(args, "test_mode", True) and "Test mode enabled." or "Test mode disabled.")

total_start = time.time()
step_times = []
total_clients = 0
Expand Down Expand Up @@ -511,8 +543,9 @@ def main() -> int:
run_step_4_generate_notices(
output_dir,
run_id,
DEFAULT_TEMPLATES_ASSETS_DIR,
assets_dir,
config_dir,
templates_package=templates_pkg,
)
step_duration = time.time() - step_start
step_times.append(("Template Generation", step_duration))
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ exclude_lines = [
"raise NotImplementedError",
"if __name__ == .__main__.:",
"if TYPE_CHECKING:",
"print"
]

[tool.coverage.html]
Expand Down
13 changes: 8 additions & 5 deletions templates/conf.typ
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#let header_info_cim(
logo,
logo_width,
fill_colour,
custom_size,
custom_msg
Expand All @@ -16,7 +17,7 @@

columns: (50%,50%),
gutter: 5%,
[#image(logo, width: 6cm)],
[#image(logo, width: logo_width)],
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is logo width and envelope width coming from config file?

[#set align(center + bottom)
#text(size: custom_size, fill: fill_colour)[*#custom_msg*]]

Expand All @@ -29,7 +30,8 @@
client_data,
client_id,
font_size,
school_type
school_type,
envelope_window_height
) = {
// Define column widths based on equal_split
let columns = if equal_split {
Expand Down Expand Up @@ -59,7 +61,7 @@
let table_content = align(center)[
#table(
columns: columns,
rows: (81pt),
rows: (envelope_window_height),
inset: font_size,
col1_content,
table.vline(stroke: vline_stroke),
Expand Down Expand Up @@ -87,7 +89,8 @@
client_data,
client_id,
font_size,
school_type
school_type,
envelope_window_height
) = {
// Define column widths based on equal_split
let columns = if equal_split {
Expand Down Expand Up @@ -117,7 +120,7 @@
let table_content = align(center)[
#table(
columns: columns,
rows: (81pt),
rows: (envelope_window_height),
inset: font_size,
col1_content,
table.vline(stroke: vline_stroke),
Expand Down
4 changes: 2 additions & 2 deletions templates/en_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,11 @@

#v(0.2cm)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can these hard coded values 6cm and 81pt come from parameters?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #70


#conf.header_info_cim("__LOGO_PATH__", black, 16pt, "Request for Immunization Record")
#conf.header_info_cim("__LOGO_PATH__", 6cm, black, 16pt, "Request for Immunization Record")

#v(0.2cm)

#conf.client_info_tbl_en(equal_split: false, vline: false, client, client_id, font_size, "Childcare Centre")
#conf.client_info_tbl_en(equal_split: false, vline: false, client, client_id, font_size, "Childcare Centre", 81pt)

#v(0.3cm)

Expand Down
4 changes: 2 additions & 2 deletions templates/fr_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@

#v(0.2cm)

#conf.header_info_cim("__LOGO_PATH__", black, 16pt, "Demande de dossier d'immunisation")
#conf.header_info_cim("__LOGO_PATH__", 6cm, black, 16pt, "Demande de dossier d'immunisation")

#v(0.2cm)

#conf.client_info_tbl_fr(equal_split: false, vline: false, client, client_id, font_size, "Centre de garde d'enfants")
#conf.client_info_tbl_fr(equal_split: false, vline: false, client, client_id, font_size, "Centre de garde d'enfants", 81pt)

#v(0.3cm)

Expand Down
Loading