Skip to content

Commit 6961956

Browse files
committed
feat: Introduce sandbox attach functionality for existing facilities
- Added `sandbox_attach.py` to implement the `seed_existing_facility` function, allowing seed data to be attached to existing facilities without creating duplicates. - Enhanced `DemoSeedRunner` to resolve geo organization from the run or profile, improving flexibility in seed execution. - Implemented error handling to ensure only superusers can initiate the seed run. - Created unit tests in `test_sandbox_attach.py` to validate the new functionality and ensure proper error handling and execution flow. - Updated `seed_runs.py` with a new `create_attached_seed_run` function to facilitate the creation of seed runs for existing facilities.
1 parent a9126d1 commit 6961956

4 files changed

Lines changed: 597 additions & 2 deletions

File tree

src/care_demo_facility_setup/services/runner.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,25 @@ def __init__(self, run: SeedRun):
2424
self.run = run
2525
self.pack = load_seed_pack(run.pack_slug)
2626
profile = load_profile(run.pack_slug, run.profile_slug)
27+
geo_organization = self._resolve_geo_organization(run, profile)
2728
self.context = SeedContext(
2829
client=CareSeedClient(run.requested_by),
2930
artifacts=SeedArtifactStore(run),
30-
geo_organization=profile["geo_organization_external_id"],
31+
geo_organization=geo_organization,
3132
run=run,
3233
pack=self.pack,
3334
profile=profile,
3435
)
3536

37+
@staticmethod
38+
def _resolve_geo_organization(run: SeedRun, profile: dict) -> str:
39+
"""Prefer caller-injected geo (attach/sandbox); fall back to profile."""
40+
payload = run.request_payload or {}
41+
geo = payload.get("geo_organization_external_id") or profile.get("geo_organization_external_id")
42+
if not geo:
43+
raise SeedRunExecutionError("No geo_organization_external_id on the seed run or profile.")
44+
return str(geo)
45+
3646
def execute(self):
3747
if self.run.status in {
3848
SeedRunStatus.SUCCEEDED,
@@ -48,6 +58,13 @@ def execute(self):
4858
self._mark_run(SeedRunStatus.RUNNING, started=True)
4959
try:
5060
for step_definition in get_executable_seed_step_definitions(self.pack["manifest"]):
61+
step = self._get_step(step_definition.key)
62+
# Attach mode (and any pre-completed step) must not re-run.
63+
if step.status in {
64+
SeedRunStepStatus.SUCCEEDED,
65+
SeedRunStepStatus.SKIPPED,
66+
}:
67+
continue
5168
self._execute_seed_step(step_definition)
5269
except Exception as exc:
5370
self._mark_pending_steps_skipped("Skipped because an earlier step failed.")
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""Programmatic attach-to-existing-facility entrypoint for sandbox callers.
2+
3+
Seeds pack content onto a facility that already exists (e.g. Experience sandbox
4+
shell). Does not create a second facility. Runs DemoSeedRunner.execute()
5+
synchronously — never enqueue_seed_run / .delay().
6+
"""
7+
8+
from __future__ import annotations
9+
10+
from care_demo_facility_setup.models import SeedRun, SeedRunStatus, SeedRunStepStatus
11+
from care_demo_facility_setup.services.runner import DemoSeedRunner
12+
from care_demo_facility_setup.services.seed_errors import SeedRunExecutionError
13+
from care_demo_facility_setup.services.seed_packs import DEFAULT_PACK_SLUG
14+
from care_demo_facility_setup.services.seed_runs import create_attached_seed_run
15+
16+
# Clearer panel keys than raw step keys (title-case still works either way).
17+
_SUMMARY_KEYS = {
18+
"inventory_items": "product_knowledges",
19+
}
20+
21+
_STAT_LABELS = {
22+
"created": "created",
23+
"attached": "attached",
24+
"reused": "reused",
25+
"products_received": "received",
26+
"transferred": "transferred",
27+
"categories_created": "categories",
28+
"departments_created": "departments",
29+
"departments_reused": "departments reused",
30+
"locations_created": "locations",
31+
"healthcare_services_created": "services",
32+
"op_closed": "OP",
33+
"ip_in_progress": "IP",
34+
"emergency": "emergency",
35+
"beds_assigned": "beds",
36+
}
37+
38+
39+
def seed_existing_facility(
40+
*,
41+
facility_external_id: str,
42+
geo_organization_external_id: str,
43+
requested_by,
44+
pack_slug: str = DEFAULT_PACK_SLUG,
45+
profile_slug: str = "local",
46+
) -> SeedRun:
47+
"""Attach pack seed data to an existing facility and execute synchronously.
48+
49+
Returns the SeedRun on SUCCEEDED. Raises SeedRunExecutionError (or the
50+
underlying step exception) on failure so the sandbox task can mark the job
51+
failed.
52+
"""
53+
if not requested_by or not getattr(requested_by, "is_superuser", False):
54+
raise SeedRunExecutionError("Seed run must be requested by a superuser.")
55+
56+
run = create_attached_seed_run(
57+
facility_external_id=str(facility_external_id),
58+
geo_organization_external_id=str(geo_organization_external_id),
59+
requested_by=requested_by,
60+
pack_slug=pack_slug,
61+
profile_slug=profile_slug,
62+
)
63+
DemoSeedRunner(run).execute()
64+
run.refresh_from_db()
65+
if run.status != SeedRunStatus.SUCCEEDED:
66+
raise SeedRunExecutionError(run.error or "Attached seed run did not succeed.")
67+
return run
68+
69+
70+
def _is_int(value) -> bool:
71+
return isinstance(value, int) and not isinstance(value, bool)
72+
73+
74+
def _join_parts(parts: list[str]) -> str | None:
75+
return " · ".join(parts) if parts else None
76+
77+
78+
def _format_stats_summary(key: str, stats) -> str | None:
79+
"""Build a short labeled string from step stats when message is absent."""
80+
if stats is True or stats is None or stats == {}:
81+
return None
82+
if isinstance(stats, bool):
83+
return None
84+
if _is_int(stats):
85+
return str(stats)
86+
if not isinstance(stats, dict):
87+
return None
88+
89+
if key in {"inventory_items", "product_knowledges"}:
90+
created = stats.get("created")
91+
received = stats.get("products_received")
92+
transferred = stats.get("transferred")
93+
parts = []
94+
if _is_int(created):
95+
parts.append(f"{created} product knowledges")
96+
if _is_int(received):
97+
parts.append(f"{received} received")
98+
if _is_int(transferred):
99+
parts.append(f"{transferred} transferred")
100+
if parts:
101+
return _join_parts(parts)
102+
103+
if key == "clinical_visits":
104+
created = stats.get("created")
105+
if _is_int(created):
106+
breakdown = []
107+
for stat_key, label in (
108+
("op_closed", "OP"),
109+
("ip_in_progress", "IP"),
110+
("emergency", "emergency"),
111+
):
112+
value = stats.get(stat_key)
113+
if _is_int(value) and value:
114+
breakdown.append(f"{value} {label}")
115+
if breakdown:
116+
return f"{created} ({', '.join(breakdown)})"
117+
return str(created)
118+
119+
if key == "facility":
120+
attached = stats.get("attached")
121+
created = stats.get("created")
122+
parts = []
123+
if _is_int(attached) and attached:
124+
parts.append(f"{attached} attached")
125+
if _is_int(created) and created:
126+
parts.append(f"{created} created")
127+
if parts:
128+
return ", ".join(parts)
129+
if _is_int(attached):
130+
return "attached" if attached else "0 attached"
131+
if _is_int(created):
132+
return f"{created} created"
133+
134+
if key == "facility_foundation":
135+
parts = []
136+
for stat_key, label in (
137+
("departments_created", "departments"),
138+
("departments_reused", "departments reused"),
139+
("locations_created", "locations"),
140+
("healthcare_services_created", "services"),
141+
):
142+
value = stats.get(stat_key)
143+
if _is_int(value) and value:
144+
parts.append(f"{value} {label}")
145+
if parts:
146+
return _join_parts(parts)
147+
148+
if key == "questionnaires":
149+
created = stats.get("created")
150+
reused = stats.get("reused")
151+
parts = []
152+
if _is_int(created):
153+
parts.append(f"{created} created")
154+
if _is_int(reused):
155+
parts.append(f"{reused} reused")
156+
if parts:
157+
return ", ".join(parts)
158+
159+
parts = []
160+
for stat_key, value in stats.items():
161+
if not _is_int(value):
162+
continue
163+
label = _STAT_LABELS.get(stat_key, stat_key.replace("_", " "))
164+
parts.append(f"{value} {label}")
165+
return _join_parts(parts)
166+
167+
168+
def _step_summary(step) -> str | None:
169+
"""Human-readable value for one succeeded seed step."""
170+
message = getattr(step, "message", None)
171+
if isinstance(message, str) and message.strip():
172+
return message.strip()
173+
return _format_stats_summary(step.key, getattr(step, "stats", None))
174+
175+
176+
def summarize_seed_run(run: SeedRun) -> dict:
177+
"""Map SeedRun steps into readable summaries for the Experience sandbox panel.
178+
179+
Values are short strings (or rarely plain ints) derived from each step's
180+
seeder ``message`` when present, otherwise from labeled ``stats``. Debug
181+
identifiers live under ``_meta`` (Experience skips ``_`` keys). Failed
182+
steps are omitted.
183+
"""
184+
loaded: dict = {
185+
"_meta": {
186+
"seed_run_id": str(run.external_id),
187+
"pack_slug": run.pack_slug,
188+
"profile_slug": run.profile_slug,
189+
},
190+
}
191+
for step in run.steps.all().order_by("order"):
192+
if step.key == "validate":
193+
continue
194+
status = step.status.value if hasattr(step.status, "value") else step.status
195+
if status != SeedRunStepStatus.SUCCEEDED:
196+
continue
197+
summary = _step_summary(step)
198+
if summary is not None:
199+
loaded[_SUMMARY_KEYS.get(step.key, step.key)] = summary
200+
return loaded

src/care_demo_facility_setup/services/seed_runs.py

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
from __future__ import annotations
22

3+
from care.emr.models.organization import Organization
4+
from care.facility.models import Facility
35
from django.db import transaction
46
from django.utils import timezone
57

68
from care_demo_facility_setup.models import (
79
SeedRun,
10+
SeedRunArtifact,
811
SeedRunStatus,
912
SeedRunStep,
1013
SeedRunStepStatus,
1114
)
12-
from care_demo_facility_setup.services.seed_packs import SeedPackError, load_seed_pack
15+
from care_demo_facility_setup.services.seed_errors import SeedRunExecutionError
16+
from care_demo_facility_setup.services.seed_packs import (
17+
DEFAULT_PACK_SLUG,
18+
SeedPackError,
19+
load_profile,
20+
load_seed_pack,
21+
)
1322
from care_demo_facility_setup.services.seed_step_registry import SeedStepRegistryError, get_seed_step_definitions
1423
from care_demo_facility_setup.services.seed_validation import validate_seed_request
1524

@@ -143,3 +152,121 @@ def enqueue_seed_run(run_external_id: str):
143152
error=f"Could not enqueue seed run: {exc}",
144153
finished_date=timezone.now(),
145154
)
155+
156+
157+
@transaction.atomic
158+
def create_attached_seed_run(
159+
*,
160+
facility_external_id: str,
161+
geo_organization_external_id: str,
162+
requested_by,
163+
pack_slug: str = DEFAULT_PACK_SLUG,
164+
profile_slug: str = "local",
165+
) -> SeedRun:
166+
"""Create a SeedRun that attaches pack content to an existing facility.
167+
168+
Skips host/profile geo validation: the caller supplies geo and facility ids.
169+
Pre-stores ``facility:main`` and marks the facility step succeeded so
170+
FacilitySeeder is never called. Standalone ``create_seed_run`` is unchanged.
171+
"""
172+
try:
173+
pack = load_seed_pack(pack_slug)
174+
profile = load_profile(pack_slug, profile_slug)
175+
except SeedPackError as exc:
176+
raise SeedRunExecutionError(str(exc)) from exc
177+
178+
if not Organization.objects.filter(
179+
external_id=geo_organization_external_id,
180+
org_type="govt",
181+
).exists():
182+
raise SeedRunExecutionError("geo_organization_external_id does not match an existing govt organization.")
183+
184+
try:
185+
facility = Facility.objects.get(external_id=facility_external_id)
186+
except Facility.DoesNotExist as exc:
187+
raise SeedRunExecutionError(f"Facility {facility_external_id} does not exist.") from exc
188+
189+
facility_ref = pack.get("facility", {}).get("ref", "facility:main")
190+
manifest = pack["manifest"]
191+
now = timezone.now()
192+
request_payload = {
193+
"pack_slug": pack_slug,
194+
"profile_slug": profile_slug,
195+
"dry_run": False,
196+
"attach_existing_facility": True,
197+
"facility_external_id": str(facility_external_id),
198+
"geo_organization_external_id": str(geo_organization_external_id),
199+
}
200+
summary = {
201+
"pack_slug": manifest["slug"],
202+
"pack_name": manifest["name"],
203+
"pack_version": manifest["version"],
204+
"profile_slug": profile["slug"],
205+
"profile_name": profile["name"],
206+
"counts": manifest.get("counts", {}),
207+
"geo_organization_external_id": str(geo_organization_external_id),
208+
"facility_external_id": str(facility_external_id),
209+
"attach_existing_facility": True,
210+
"resource_categories": profile.get("resource_categories", {}),
211+
}
212+
213+
run = SeedRun.objects.create(
214+
pack_slug=pack_slug,
215+
profile_slug=profile_slug,
216+
dry_run=False,
217+
requested_by=requested_by,
218+
request_payload=request_payload,
219+
summary=summary,
220+
status=SeedRunStatus.QUEUED,
221+
error="",
222+
)
223+
224+
facility_step = None
225+
for index, step_definition in enumerate(_planned_steps_for_pack(pack_slug), start=1):
226+
if step_definition.key == "validate":
227+
status = SeedRunStepStatus.SUCCEEDED
228+
message = "Attach-mode validation passed (geo and facility injected)."
229+
started = now
230+
finished = now
231+
stats = summary.get("counts", {})
232+
elif step_definition.key == "facility":
233+
status = SeedRunStepStatus.SUCCEEDED
234+
message = f"Attached existing facility {facility.name}"
235+
started = now
236+
finished = now
237+
stats = {"created": 0, "attached": 1}
238+
else:
239+
status = SeedRunStepStatus.PENDING
240+
message = ""
241+
started = None
242+
finished = None
243+
stats = {}
244+
245+
step = SeedRunStep.objects.create(
246+
run=run,
247+
order=index,
248+
key=step_definition.key,
249+
title=step_definition.title,
250+
status=status,
251+
message=message,
252+
stats=stats,
253+
started_date=started,
254+
finished_date=finished,
255+
)
256+
if step_definition.key == "facility":
257+
facility_step = step
258+
259+
SeedRunArtifact.objects.create(
260+
run=run,
261+
step=facility_step,
262+
ref=facility_ref,
263+
resource_type="Facility",
264+
resource_external_id=facility.external_id,
265+
slug=getattr(facility, "slug", "") or "",
266+
payload={
267+
"id": str(facility.external_id),
268+
"name": facility.name,
269+
"attached": True,
270+
},
271+
)
272+
return run

0 commit comments

Comments
 (0)