Refactor and extend puck handling with tell_position updates

Refactored puck updating logic to use Pydantic models for validation and normalization. Added a new endpoint to retrieve all pucks with a tell_position and their associated samples. Updated project version to 0.1.0a11 in pyproject.toml.
This commit is contained in:
GotthardG 2025-01-08 14:11:34 +01:00
parent 6d67d02259
commit 4f73f41717
3 changed files with 185 additions and 88 deletions

View File

@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, status, Depends from fastapi import APIRouter, HTTPException, status, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, load_only
from sqlalchemy.sql import func from sqlalchemy.sql import func
from typing import List from typing import List
import uuid import uuid
@ -9,6 +9,9 @@ from app.schemas import (
Puck as PuckSchema, Puck as PuckSchema,
PuckCreate, PuckCreate,
PuckUpdate, PuckUpdate,
PuckWithTellPosition,
Sample,
SetTellPosition,
) )
from app.models import ( from app.models import (
Puck as PuckModel, Puck as PuckModel,
@ -42,48 +45,61 @@ async def get_pucks(db: Session = Depends(get_db)):
@router.put("/set-tell-positions", status_code=status.HTTP_200_OK) @router.put("/set-tell-positions", status_code=status.HTTP_200_OK)
async def set_tell_positions( async def set_tell_positions(
pucks: List[dict], # Accept a list of puck definitions from the client pucks: List[SetTellPosition], # Accept a list of SetTellPosition Pydantic models
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
""" """
Set the tell positions for multiple pucks. Set the tell positions for multiple pucks based on the business rules.
Args: Args:
pucks (List[dict]): A list of puck definitions, where each puck contains: pucks (List[SetTellPosition]): A list of puck definitions
- `puck_name` (str): The cleaned name of the puck. with potential new positions:
- `segment` (str): The segment in the dewar (e.g., "A-F"). - `puck_name` (Optional[str]): The name of the puck to update.
- `puck_in_segment` (int): The position within the segment (1-5). - `segment` (Optional[str]): The segment (A-F) in the dewar.
- `puck_in_segment` (Optional[int]): The position within the segment (1-5).
Returns: Returns:
List[dict]: A list of responses, one for each successfully processed puck. List[dict]: A list of results indicating the status of each puck update.
""" """
results = [] results = []
# Helper function: Validate and normalize puck names
def normalize_puck_name(name: str) -> str:
return str(name).strip().replace(" ", "_").upper()
if not pucks:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payload cannot be empty. Provide at least one puck.",
)
for puck_data in pucks: for puck_data in pucks:
try: try:
# Extract data from input # Step 1: Extract data from the Pydantic model
puck_name = puck_data.get("puckname") puckname = puck_data.puckname
segment = puck_data.get("segment") tell_position = (
puck_in_segment = puck_data.get("puck_in_segment") puck_data.tell_position
) # Combines `segment` + `puck_in_segment`
# 1. Validate `segment` and `puck_in_segment` if not puckname:
if not segment or segment not in "ABCDEF": # Step 3: If `puckname` is missing, clear tell positions for ALL pucks
raise ValueError( db.query(PuckEventModel).filter(
f"Invalid segment '{segment}'. Must be A, B, C, D, E, or F." PuckEventModel.tell_position.isnot(None)
) ).update({"tell_position": None})
if not (1 <= puck_in_segment <= 5): db.commit()
raise ValueError( # Add the result for the clean-up case
f"Invalid puck_in_segment " results.append(
f"'{puck_in_segment}'. Must be in range 1-5." {
"action": "clear",
"message": "Tell positions cleared for all pucks.",
}
) )
break # No need to process further since all tell positions are cleared
# Generate tell_position # Step 2: Normalize puck name for database lookups
tell_position = f"{segment}{puck_in_segment}" normalized_name = normalize_puck_name(puckname)
# 2. Find the puck by its cleaned name # Query the puck from the database
normalized_name = normalize_puck_name(puck_name)
# Use SQLAlchemy to match normalized names
puck = ( puck = (
db.query(PuckModel) db.query(PuckModel)
.filter( .filter(
@ -94,76 +110,86 @@ async def set_tell_positions(
) )
if not puck: if not puck:
raise ValueError( raise ValueError(f"Puck with name '{puckname}' not found.")
f"Puck with cleaned name '{puck_name}' not found in the database."
)
# 3. Find the dewar associated with the puck # Query the most recent tell_position for this puck
dewar = db.query(DewarModel).filter(DewarModel.id == puck.dewar_id).first() last_tell_position_event = (
if not dewar: db.query(PuckEventModel)
raise ValueError(f"Dewar associated with puck '{puck_name}' not found.")
# 4. Find the most recent logistics event for the dewar (type 'beamline')
logistics_event = (
db.query(LogisticsEventModel)
.filter( .filter(
LogisticsEventModel.dewar_id == dewar.id, PuckEventModel.puck_id == puck.id,
LogisticsEventModel.event_type == "beamline", PuckEventModel.tell_position.isnot(None),
) )
.order_by(LogisticsEventModel.timestamp.desc()) .order_by(PuckEventModel.timestamp.desc())
.first() .first()
) )
if not logistics_event:
raise ValueError(
f"No recent 'beamline' logistics event found for dewar "
f"'{dewar.dewar_name}'."
)
# 5. Retrieve the slot from the logistics event if tell_position:
slot = ( # Step 4: Compare `tell_position` in the payload with the current one
db.query(SlotModel) if (
.filter(SlotModel.id == logistics_event.slot_id) last_tell_position_event
.first() and last_tell_position_event.tell_position == tell_position
) ):
if not slot: # Step 4.1: If positions match, do nothing
raise ValueError( results.append(
f"No slot associated with the most recent 'beamline' " {
f"logistics event " "puck_name": puck.puck_name,
f"for dewar '{dewar.dewar_name}'." "current_position": tell_position,
"status": "unchanged",
"message": "The tell_position remains the same.",
}
) )
continue # Skip to the next puck
# 6. Create a PuckEvent to set the 'tell_position' # Step 4.2: If the position is different, update it
new_puck_event = PuckEventModel( if last_tell_position_event:
# Clear the previous tell_position (set it to null)
last_tell_position_event.tell_position = None
db.add(last_tell_position_event)
# Add a new event with the updated tell_position
new_event = PuckEventModel(
puck_id=puck.id, puck_id=puck.id,
tell_position=tell_position, tell_position=tell_position,
event_type="tell_position_set", event_type="tell_position_set",
timestamp=datetime.utcnow(), timestamp=datetime.utcnow(),
) )
db.add(new_puck_event) db.add(new_event)
db.commit()
db.refresh(new_puck_event)
# Add success result to the results list
results.append( results.append(
{ {
"puck_id": puck.id,
"puck_name": puck.puck_name, "puck_name": puck.puck_name,
"dewar_id": dewar.id, "previous_position": last_tell_position_event.tell_position
"dewar_name": dewar.dewar_name, if last_tell_position_event
"slot_id": slot.id, else None,
"slot_label": slot.label, "new_position": tell_position,
"tell_position": tell_position, "status": "updated",
"event_timestamp": new_puck_event.timestamp, "message": "The tell_position was updated successfully.",
} }
) )
else:
# Step 5: If the new tell_position is None, clear the current one
if last_tell_position_event:
last_tell_position_event.tell_position = None
db.add(last_tell_position_event)
results.append(
{
"puck_name": puck.puck_name,
"previous_position": last_tell_position_event.tell_position
if last_tell_position_event
else None,
"new_position": None,
"status": "cleared",
"message": "The tell_position was cleared.",
}
)
# Commit transaction for updating tell_position
db.commit()
except Exception as e: except Exception as e:
# Handle errors for individual pucks and continue processing others # Handle errors for individual pucks and continue processing others
results.append( results.append({"puck_name": puckname, "error": str(e)})
{
"puck_name": puck_name,
"error": str(e),
}
)
return results return results
@ -356,3 +382,62 @@ async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db))
# Return the list of pucks with their associated dewar names # Return the list of pucks with their associated dewar names
return puck_output return puck_output
class SampleModel:
pass
@router.get("/with-tell-position", response_model=List[PuckWithTellPosition])
async def get_pucks_with_tell_position(db: Session = Depends(get_db)):
"""
Retrieve all pucks with a `tell_position` set (not null)
and their associated samples.
"""
# Query pucks with events where `tell_position` is not null
pucks = (
db.query(PuckModel)
.join(PuckEventModel, PuckModel.id == PuckEventModel.puck_id)
.filter(PuckEventModel.tell_position.isnot(None))
.options(
load_only(
PuckModel.id,
PuckModel.puck_name,
PuckModel.puck_type,
PuckModel.puck_location_in_dewar,
PuckModel.dewar_id,
)
)
.all()
)
if not pucks:
raise HTTPException(
status_code=404,
detail="No pucks with a `tell_position` found.",
)
results = [
PuckWithTellPosition(
id=int(puck.id), # Explicit casting
puck_name=str(puck.puck_name),
puck_type=str(puck.puck_type),
puck_location_in_dewar=str(puck.puck_location_in_dewar)
if puck.puck_location_in_dewar
else None,
dewar_id=int(puck.dewar_id),
samples=[
Sample(
id=sample.id,
sample_name=sample.sample_name,
position=sample.position,
)
for sample in db.query(SampleModel)
.filter(SampleModel.puck_id == puck.id)
.all()
],
)
for puck in pucks
]
return results

View File

@ -613,7 +613,7 @@ class SlotSchema(BaseModel):
class SetTellPosition(BaseModel): class SetTellPosition(BaseModel):
puckname: str # The puck name is required. puck_name: str
segment: Optional[str] = Field( segment: Optional[str] = Field(
None, None,
pattern="^[A-F]$", # Valid segments are A, B, C, D, E, F pattern="^[A-F]$", # Valid segments are A, B, C, D, E, F
@ -637,3 +637,15 @@ class SetTellPosition(BaseModel):
if self.segment and self.puck_in_segment: if self.segment and self.puck_in_segment:
return f"{self.segment}{self.puck_in_segment}" return f"{self.segment}{self.puck_in_segment}"
return None return None
class PuckWithTellPosition(BaseModel):
id: int
puck_name: str
puck_type: str
puck_location_in_dewar: Optional[str]
dewar_id: int
samples: List[Sample]
class Config:
from_attributes = True

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "aareDB" name = "aareDB"
version = "0.1.0a10" version = "0.1.0a11"
description = "Backend for next gen sample management system" description = "Backend for next gen sample management system"
authors = [{name = "Guillaume Gotthard", email = "guillaume.gotthard@psi.ch"}] authors = [{name = "Guillaume Gotthard", email = "guillaume.gotthard@psi.ch"}]
license = {text = "MIT"} license = {text = "MIT"}