Skip to content

Commit af6b81b

Browse files
committed
Add Bloom container action API and GUI
1 parent 94f0898 commit af6b81b

6 files changed

Lines changed: 530 additions & 0 deletions

File tree

bloom_lims/api/v1/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from .batch import router as batch_router
2222
from .beta_lab import router as beta_lab_router
2323
from .containers import router as containers_router
24+
from .container_actions import router as container_actions_router
2425
from .content import router as content_router
2526
from .equipment import router as equipment_router
2627
from .execution_queue import router as execution_queue_router
@@ -45,6 +46,7 @@
4546
router.include_router(auth_router)
4647
router.include_router(preferences_router)
4748
router.include_router(containers_router)
49+
router.include_router(container_actions_router)
4850
router.include_router(content_router)
4951
router.include_router(equipment_router)
5052
router.include_router(execution_queue_router)
@@ -77,6 +79,7 @@ async def api_v1_info():
7779
"objects": "/api/v1/objects",
7880
"auth": "/api/v1/auth",
7981
"containers": "/api/v1/containers",
82+
"container_actions": "/api/v1/container-actions",
8083
"content": "/api/v1/content",
8184
"equipment": "/api/v1/equipment",
8285
"execution": "/api/v1/execution",
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""Generic Bloom container-action validation facade."""
2+
3+
from __future__ import annotations
4+
5+
import csv
6+
from io import StringIO
7+
from typing import Any, Literal
8+
9+
from fastapi import APIRouter, Depends, HTTPException
10+
from pydantic import BaseModel, Field, ValidationError
11+
from sqlalchemy import select
12+
13+
from bloom_lims.tapdb_adapter import BLOOMdb3, generic_instance
14+
from bloom_lims.domain.lab_actions import LabActionsService
15+
from bloom_lims.schemas.lab_actions import (
16+
ExtractionPlateRequest,
17+
ExtractionQcPlateRequest,
18+
LabSetRequest,
19+
PlateWellDataRequest,
20+
PrintEuidRequest,
21+
SeqLibraryPlateRequest,
22+
SeqLibraryPoolRequest,
23+
SeqRunSetRequest,
24+
)
25+
26+
from .dependencies import APIUser, require_write
27+
28+
router = APIRouter(prefix="/container-actions", tags=["Container Actions"])
29+
30+
31+
class ContainerActionScanRequest(BaseModel):
32+
rows: str | None = None
33+
records: list[dict[str, Any]] = Field(default_factory=list)
34+
default_target: str | None = None
35+
child_name: str | None = None
36+
child_description: str | None = None
37+
child_content_type: str | None = None
38+
39+
40+
ContainerActionOperation = Literal[
41+
"extraction_plate",
42+
"extraction_qc_plate",
43+
"plate_well_data",
44+
"seq_library_plate",
45+
"seq_library_pool",
46+
"seq_run_set",
47+
"lab_set",
48+
"print_euids",
49+
]
50+
51+
52+
class ContainerActionExecuteRequest(ContainerActionScanRequest):
53+
operation_type: ContainerActionOperation | None = None
54+
operation_payload: dict[str, Any] | None = None
55+
56+
57+
def _service_for_user(user: APIUser) -> LabActionsService:
58+
return LabActionsService(
59+
BLOOMdb3(app_username=user.email),
60+
user_id=user.user_id,
61+
email=user.email,
62+
)
63+
64+
65+
def _split_source(value: str) -> tuple[str, str | None]:
66+
source = str(value or "").strip()
67+
if not source:
68+
raise ValueError("source EUID is required")
69+
if "." not in source:
70+
return source, None
71+
euid, well = source.rsplit(".", 1)
72+
euid = euid.strip()
73+
well = well.strip().upper()
74+
if not euid or not well:
75+
raise ValueError(f"Invalid source well reference: {value!r}")
76+
return euid, well
77+
78+
79+
def _normalize_record(row: dict[str, Any], default_target: str | None) -> dict[str, Any]:
80+
source = row.get("source") or row.get("source_euid") or row.get("euid") or row.get("0")
81+
target = (
82+
row.get("target")
83+
or row.get("target_container_euid")
84+
or row.get("target_container")
85+
or row.get("1")
86+
or default_target
87+
)
88+
target_well = row.get("target_well") or row.get("well") or row.get("2")
89+
source_euid, source_well = _split_source(str(source or ""))
90+
target_text = str(target or "").strip()
91+
if not target_text:
92+
raise ValueError(f"Target is required for source {source_euid}")
93+
create_type = None
94+
target_euid = target_text
95+
if target_text.upper().startswith("CREATE."):
96+
create_type = target_text.split(".", 1)[1].strip()
97+
target_euid = None
98+
if not create_type:
99+
raise ValueError(f"CREATE target requires a type for source {source_euid}")
100+
return {
101+
"source_euid": source_euid,
102+
"source_well": source_well,
103+
"target_euid": target_euid,
104+
"create_type": create_type,
105+
"target_well": str(target_well or "").strip().upper() or None,
106+
"mapping_mode": "directed" if target_well else "auto",
107+
}
108+
109+
110+
def _parse_rows(payload: ContainerActionScanRequest) -> list[dict[str, Any]]:
111+
records = list(payload.records)
112+
if payload.rows:
113+
sample = payload.rows[:1024]
114+
try:
115+
dialect = csv.Sniffer().sniff(sample, delimiters=",\t")
116+
except csv.Error:
117+
dialect = csv.excel
118+
reader = csv.reader(StringIO(payload.rows), dialect)
119+
for row in reader:
120+
if not row or not any(str(cell).strip() for cell in row):
121+
continue
122+
records.append({str(index): value for index, value in enumerate(row)})
123+
if not records:
124+
raise ValueError("rows or records is required")
125+
return [_normalize_record(row, payload.default_target) for row in records]
126+
127+
128+
def _object_kind(euid: str, user: APIUser) -> dict[str, Any]:
129+
db = BLOOMdb3(app_username=user.email)
130+
try:
131+
instance = db.session.execute(
132+
select(generic_instance).where(
133+
generic_instance.euid == euid,
134+
generic_instance.is_deleted.is_(False),
135+
)
136+
).scalar_one_or_none()
137+
if instance is None:
138+
return {"euid": euid, "exists": False, "kind": None}
139+
return {
140+
"euid": euid,
141+
"exists": True,
142+
"kind": "/".join(
143+
[
144+
str(getattr(instance, "category", "") or ""),
145+
str(getattr(instance, "type", "") or ""),
146+
str(getattr(instance, "subtype", "") or ""),
147+
str(getattr(instance, "version", "") or ""),
148+
]
149+
),
150+
}
151+
finally:
152+
db.close()
153+
154+
155+
@router.post("/validate")
156+
async def validate_container_action(
157+
payload: ContainerActionScanRequest,
158+
user: APIUser = Depends(require_write),
159+
):
160+
try:
161+
planned = _parse_rows(payload)
162+
source_types = {row["source_euid"]: _object_kind(row["source_euid"], user) for row in planned}
163+
target_types = {
164+
row["target_euid"]: _object_kind(row["target_euid"], user)
165+
for row in planned
166+
if row["target_euid"]
167+
}
168+
return {
169+
"valid": True,
170+
"row_count": len(planned),
171+
"planned": planned,
172+
"source_types": source_types,
173+
"target_types": target_types,
174+
"creates": [row for row in planned if row["create_type"]],
175+
}
176+
except (ValueError, ValidationError) as exc:
177+
raise HTTPException(status_code=400, detail=str(exc)) from exc
178+
179+
180+
@router.post("/execute")
181+
async def execute_container_action(
182+
payload: ContainerActionExecuteRequest,
183+
user: APIUser = Depends(require_write),
184+
):
185+
validation = await validate_container_action(payload, user)
186+
if not payload.operation_type:
187+
raise HTTPException(
188+
status_code=400,
189+
detail={
190+
"message": (
191+
"operation_type is required. Container actions do not infer "
192+
"execution behavior from scanned rows."
193+
),
194+
"validation": validation,
195+
},
196+
)
197+
if not payload.operation_payload:
198+
raise HTTPException(
199+
status_code=400,
200+
detail={
201+
"message": "operation_payload is required for container-action execution.",
202+
"operation_type": payload.operation_type,
203+
"validation": validation,
204+
},
205+
)
206+
207+
service = _service_for_user(user)
208+
try:
209+
operation_payload = payload.operation_payload
210+
if payload.operation_type == "extraction_plate":
211+
result = service.create_extraction_plate(
212+
ExtractionPlateRequest.model_validate(operation_payload)
213+
)
214+
elif payload.operation_type == "extraction_qc_plate":
215+
result = service.fill_extraction_qc_plate(
216+
ExtractionQcPlateRequest.model_validate(operation_payload)
217+
)
218+
elif payload.operation_type == "plate_well_data":
219+
result = service.attach_plate_well_data(
220+
PlateWellDataRequest.model_validate(operation_payload)
221+
)
222+
elif payload.operation_type == "seq_library_plate":
223+
result = service.create_seq_library_plate(
224+
SeqLibraryPlateRequest.model_validate(operation_payload)
225+
)
226+
elif payload.operation_type == "seq_library_pool":
227+
result = service.create_seq_library_pool(
228+
SeqLibraryPoolRequest.model_validate(operation_payload)
229+
)
230+
elif payload.operation_type == "seq_run_set":
231+
result = service.create_seq_run_set(
232+
SeqRunSetRequest.model_validate(operation_payload)
233+
)
234+
elif payload.operation_type == "lab_set":
235+
result = service.create_lab_set(LabSetRequest.model_validate(operation_payload))
236+
elif payload.operation_type == "print_euids":
237+
result = service.print_euids(PrintEuidRequest.model_validate(operation_payload))
238+
else: # pragma: no cover - Literal validation should prevent this.
239+
raise HTTPException(status_code=400, detail="Unsupported operation_type")
240+
return {
241+
"operation_type": payload.operation_type,
242+
"validation": validation,
243+
"result": result,
244+
}
245+
except ValueError as exc:
246+
raise HTTPException(status_code=400, detail=str(exc)) from exc
247+
finally:
248+
service.close()

bloom_lims/gui/routes/modern.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ async def lab_actions_wizard(request: Request, _auth=Depends(require_auth)):
175175
return HTMLResponse(content=template.render(context), status_code=200)
176176

177177

178+
@router.get("/container-actions", response_class=HTMLResponse)
179+
async def container_actions_wizard(request: Request, _auth=Depends(require_auth)):
180+
user_data = request.session.get("user_data", {})
181+
template = templates.get_template("modern/container_actions.html")
182+
context = {
183+
"request": request,
184+
"udat": user_data,
185+
"user": user_data,
186+
"page_title": "Container Actions",
187+
}
188+
return HTMLResponse(content=template.render(context), status_code=200)
189+
190+
178191
@router.get("/help", response_class=HTMLResponse)
179192
async def help_page(request: Request):
180193
user_data = request.session.get("user_data", {})

templates/modern/base.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
<a href="/lab-actions" class="{% if '/lab-actions' in request.url.path %}active{% endif %}">
7373
<i class="fas fa-route"></i> Lab Actions
7474
</a>
75+
<a href="/container-actions" class="{% if '/container-actions' in request.url.path %}active{% endif %}">
76+
<i class="fas fa-exchange-alt"></i> Container Actions
77+
</a>
7578
<div class="nav-dropdown">
7679
<a href="#" class="nav-dropdown-toggle {% if '/search' in request.url.path or '/bulk_create' in request.url.path %}active{% endif %}">
7780
<i class="fas fa-tools"></i> Tools <i class="fas fa-caret-down"></i>

0 commit comments

Comments
 (0)