diff --git a/.gitignore b/.gitignore index 9134db1..1219d48 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ prod-migrations/ .vscode test.zip .vs +node_modules/ +*.log diff --git a/docs/superpowers/plans/2026-07-20-static-treatments-deploy-fix.md b/docs/superpowers/plans/2026-07-20-static-treatments-deploy-fix.md new file mode 100644 index 0000000..80a7aba --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-static-treatments-deploy-fix.md @@ -0,0 +1,82 @@ +# Static Treatments Deploy Fix Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ensure admin-edited default treatments are exported into the shared static volume during deploy so the UI continues to read the latest treatments after a rollout. + +**Architecture:** Keep the current browser flow intact: the frontend still loads `default-treatments.json` and merges it with the `/treatments` API response. Change only the deploy path so one init container runs `export_default` and then `collectstatic` in the same filesystem context, ensuring the generated JSON is copied into the shared static volume served by nginx. This avoids a backend API rewrite and keeps the fix limited to deployment plumbing. + +**Tech Stack:** Django management commands, Helm init containers, Kubernetes persistent volumes, GitHub Actions Docker image build/deploy. + +--- + +### Task 1: Verify the current deploy/data path + +**Files:** +- Inspect: `infra/helm/qmra/templates/deployment.yaml` +- Inspect: `infra/helm/qmra/templates/volumes.yaml` +- Inspect: `infra/helm/qmra/templates/configmap.yaml` +- Inspect: `qmra/risk_assessment/admin.py` +- Inspect: `qmra/management/commands/export_default.py` + +- [ ] **Step 1: Confirm where `export_default` writes** + +Read `qmra/management/commands/export_default.py` and verify that `QMRATreatments.source` points at `qmra/static/data/default-treatments.json`. + +- [ ] **Step 2: Confirm how the deploy mounts volumes** + +Read `infra/helm/qmra/templates/deployment.yaml` and verify whether the `export-default` init container mounts the static PVC or only the `qmra-default` PVC. + +- [ ] **Step 3: Confirm the static serving path** + +Read `infra/helm/qmra/templates/volumes.yaml` and confirm that the nginx static deployment serves `/static` from the shared static PVC. + +### Task 2: Patch the deploy so the generated JSON survives into the static volume + +**Files:** +- Modify: `infra/helm/qmra/templates/deployment.yaml` + +- [ ] **Step 1: Combine export and collect into one init container** + +Replace the separate `export-default` and `move-static` init containers with a single init container that mounts both shared volumes: + +```yaml +volumeMounts: + - name: qmra-default + mountPath: {{ .Values.qmra_default.mount_path }} + - name: static + mountPath: {{ .Values.static.mount_path }} +``` + +and runs: + +```yaml +command: [ sh, -c, "python manage.py export_default && python manage.py collectstatic --noinput" ] +``` + +This keeps the generated `qmra/static/data/default-treatments.json` visible to `collectstatic` before the init container exits. + +### Task 3: Validate the manifest and deployment flow + +**Files:** +- Inspect: `infra/helm/qmra/templates/deployment.yaml` +- Inspect: `.github/workflows/deploy.yaml` + +- [ ] **Step 1: Render the Helm template** + +Run a template render or equivalent manifest check and confirm the `export-default` init container now has both the `qmra-default` and `static` mounts. + +- [ ] **Step 2: Confirm workflow behavior** + +Read `.github/workflows/deploy.yaml` and verify the image deployment path still uses the repository snapshot plus the runtime `helm upgrade`, so the updated manifest will be applied on the next deploy. + +- [ ] **Step 3: Sanity-check the change** + +Confirm there are no other changes to the treatment read path. The UI should still: + +```javascript +defaultTreatments = await fetch("{% static 'data/default-treatments.json' %}").then(resp => resp.json()); +defaultTreatments = {...defaultTreatments, ...await fetch("{% url 'treatments' %}").then(resp => resp.json())} +``` + +That behavior is intentional and should remain unchanged for this fix. diff --git a/infra/helm/qmra/templates/deployment.yaml b/infra/helm/qmra/templates/deployment.yaml index d9a6285..586fb08 100644 --- a/infra/helm/qmra/templates/deployment.yaml +++ b/infra/helm/qmra/templates/deployment.yaml @@ -37,7 +37,7 @@ spec: - name: qmra-default mountPath: {{ .Values.qmra_default.mount_path }} command: [ python, manage.py, migrate, --database, qmra ] - - name: export-default + - name: refresh-default-static image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" envFrom: - configMapRef: @@ -45,16 +45,9 @@ spec: volumeMounts: - name: qmra-default mountPath: {{ .Values.qmra_default.mount_path }} - command: [ python, manage.py, export_default ] - - name: move-static - image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" - envFrom: - - configMapRef: - name: {{ .Values.configmap_name }} - volumeMounts: - name: static mountPath: {{ .Values.static.mount_path }} - command: [ python, manage.py, collectstatic, --noinput ] + command: [ sh, -c, "python manage.py export_default && python manage.py collectstatic --noinput" ] containers: - name: {{ .Chart.Name }} image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" @@ -133,4 +126,4 @@ spec: volumes: - name: static persistentVolumeClaim: - claimName: {{ include "app.fullname" . }}-static-files-pvc \ No newline at end of file + claimName: {{ include "app.fullname" . }}-static-files-pvc diff --git a/qmra/risk_assessment/qmra_models.py b/qmra/risk_assessment/qmra_models.py index 4f2cf4f..15a2d7d 100644 --- a/qmra/risk_assessment/qmra_models.py +++ b/qmra/risk_assessment/qmra_models.py @@ -2,7 +2,9 @@ import dataclasses as dtc import enum import json +import os from itertools import groupby +from pathlib import Path from typing import Optional, Any import numpy as np @@ -69,11 +71,25 @@ def model(self) -> dtc.dataclass: def primary_key(self) -> str: pass + @classmethod + def _source_path(cls) -> Path: + static_root = os.getenv("STATIC_ROOT") + source_path = Path(cls.source) + if static_root: + try: + runtime_path = Path(static_root) / source_path.relative_to("qmra/static") + except ValueError: + runtime_path = None + else: + if runtime_path.exists(): + return runtime_path + return source_path + @classproperty def raw_data(cls) -> dict[str, dict[str, Any]]: # because an admin can change this data while the app runs, # we need _raw_data to be loaded dynamically... - with open(cls.source, "r") as f: + with open(cls._source_path(), "r") as f: cls._raw_data = json.load(f) return cls._raw_data diff --git a/qmra/risk_assessment/tests/test_static_entities.py b/qmra/risk_assessment/tests/test_static_entities.py index 7db1cc2..73790f9 100644 --- a/qmra/risk_assessment/tests/test_static_entities.py +++ b/qmra/risk_assessment/tests/test_static_entities.py @@ -1,3 +1,7 @@ +import json +import os +import tempfile +from pathlib import Path from unittest import TestCase from assertpy import assert_that from qmra.risk_assessment.qmra_models import PathogenGroup, QMRASource, QMRASources, QMRAPathogen, \ @@ -95,6 +99,45 @@ def test_choices(self): assert_that(choices[0][0]).is_instance_of(str) assert_that(choices[0][1]).is_instance_of(str) + def test_runtime_static_root_is_preferred_when_present(self): + under_test = QMRATreatments + original_static_root = os.environ.get("STATIC_ROOT") + original_raw_data = under_test._raw_data + with tempfile.TemporaryDirectory() as tmpdir: + runtime_file = Path(tmpdir) / "data" / "default-treatments.json" + runtime_file.parent.mkdir(parents=True, exist_ok=True) + runtime_file.write_text( + json.dumps({ + "Runtime treatment": { + "id": 999, + "name": "Runtime treatment", + "group": "Filtration", + "description": "loaded from runtime static root", + "bacteria_min": 1.0, + "bacteria_max": 2.0, + "viruses_min": 1.0, + "viruses_max": 2.0, + "protozoa_min": 1.0, + "protozoa_max": 2.0, + "bacteria_references": [], + "viruses_references": [], + "protozoa_references": [], + } + }), + encoding="utf-8", + ) + os.environ["STATIC_ROOT"] = tmpdir + under_test._raw_data = None + + assert_that(under_test.raw_data).contains_key("Runtime treatment") + assert_that(under_test.raw_data).does_not_contain_key("UV/H2O2") + + if original_static_root is None: + os.environ.pop("STATIC_ROOT", None) + else: + os.environ["STATIC_ROOT"] = original_static_root + under_test._raw_data = original_raw_data + class TestDefaultExposures(TestCase): expected_length = 8 @@ -118,4 +161,4 @@ def test_choices(self): # assert_that(len(choices)).is_equal_to(self.expected_length+2) # other, blank assert_that(choices[0]).is_instance_of(tuple) assert_that(choices[0][0]).is_instance_of(str) - assert_that(choices[0][1]).is_instance_of(str) \ No newline at end of file + assert_that(choices[0][1]).is_instance_of(str)