|
| 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() |
0 commit comments