Add beamtime assignment functionality for dewars and pucks

Implemented API endpoints and frontend logic to assign/unassign beamtime to dewars and pucks. Enhanced schemas, models, and styles while refactoring related frontend components for better user experience and data handling.
This commit is contained in:
GotthardG 2025-05-06 17:14:21 +02:00
parent 26f8870d04
commit 9e5734f060
6 changed files with 405 additions and 137 deletions

View File

@ -96,6 +96,7 @@ class Dewar(Base):
id = Column(Integer, primary_key=True, index=True, autoincrement=True) id = Column(Integer, primary_key=True, index=True, autoincrement=True)
pgroups = Column(String(255), nullable=False) pgroups = Column(String(255), nullable=False)
dewar_name = Column(String(255), nullable=False) dewar_name = Column(String(255), nullable=False)
created_at = Column(DateTime, default=datetime.now, nullable=False)
dewar_type_id = Column(Integer, ForeignKey("dewar_types.id"), nullable=True) dewar_type_id = Column(Integer, ForeignKey("dewar_types.id"), nullable=True)
dewar_serial_number_id = Column( dewar_serial_number_id = Column(
Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True

View File

@ -1,6 +1,7 @@
import os import os
import tempfile import tempfile
import time import time
from datetime import datetime, timedelta
import random import random
import hashlib import hashlib
from fastapi import APIRouter, HTTPException, status, Depends, Response from fastapi import APIRouter, HTTPException, status, Depends, Response
@ -21,7 +22,10 @@ from app.schemas import (
Sample, Sample,
Puck, Puck,
SampleEventResponse, SampleEventResponse,
DewarSchema, # Clearer name for schema DewarSchema,
loginData,
DewarWithPucksResponse,
PuckResponse,
) )
from app.models import ( from app.models import (
Dewar as DewarModel, Dewar as DewarModel,
@ -44,6 +48,7 @@ from reportlab.pdfgen import canvas
from app.crud import ( from app.crud import (
get_shipment_by_id, get_shipment_by_id,
) )
from app.routers.auth import get_current_user
dewar_router = APIRouter() dewar_router = APIRouter()
@ -543,6 +548,64 @@ def get_all_serial_numbers(db: Session = Depends(get_db)):
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
@dewar_router.get(
"/recent-dewars-with-pucks",
response_model=List[DewarWithPucksResponse],
operation_id="getRecentDewarsWithPucks",
)
async def get_recent_dewars_with_pucks(
db: Session = Depends(get_db), current_user: loginData = Depends(get_current_user)
):
# Get the timestamp for two months ago
two_months_ago = datetime.now() - timedelta(days=60)
# Query dewars for this user created in the last 2 months
dewars = (
db.query(DewarModel)
.options(joinedload(DewarModel.pucks)) # Eager load pucks
.filter(
DewarModel.pgroups.in_(current_user.pgroups),
DewarModel.created_at >= two_months_ago,
)
.all()
)
result = []
for dewar in dewars:
pucks = db.query(PuckModel).filter(PuckModel.dewar_id == dewar.id).all()
result.append(
DewarWithPucksResponse(
id=dewar.id,
dewar_name=dewar.dewar_name,
created_at=dewar.created_at,
pucks=[
PuckResponse(id=puck.id, puck_name=puck.puck_name) for puck in pucks
],
)
)
return result
@dewar_router.patch(
"/dewar/{dewar_id}/assign-beamtime", operation_id="assignDewarToBeamtime"
)
async def assign_beamtime_to_dewar(
dewar_id: int,
beamtime_id: int, # Use Query if you want this from ?beamtime_id=...
db: Session = Depends(get_db),
):
dewar = db.query(DewarModel).filter(DewarModel.id == dewar_id).first()
if not dewar: # <- Move check earlier!
raise HTTPException(status_code=404, detail="Dewar not found")
if beamtime_id == 0:
dewar.beamtime_id = None
else:
dewar.beamtime_id = beamtime_id
db.commit()
db.refresh(dewar)
return {"status": "success", "dewar_id": dewar.id, "beamtime_id": beamtime_id}
@dewar_router.get("/{dewar_id}", response_model=Dewar) @dewar_router.get("/{dewar_id}", response_model=Dewar)
async def get_dewar(dewar_id: int, db: Session = Depends(get_db)): async def get_dewar(dewar_id: int, db: Session = Depends(get_db)):
dewar = ( dewar = (

View File

@ -658,3 +658,21 @@ async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db))
) )
return pucks return pucks
@router.patch("/puck/{puck_id}/assign-beamtime", operation_id="assignPuckToBeamtime")
async def assign_beamtime_to_puck(
puck_id: int,
beamtime_id: int, # If you want ?beamtime_id=123 in the query
db: Session = Depends(get_db),
):
puck = db.query(PuckModel).filter(PuckModel.id == puck_id).first()
if not puck:
raise HTTPException(status_code=404, detail="Puck not found")
if beamtime_id == 0:
puck.beamtime_id = None
else:
puck.beamtime_id = beamtime_id
db.commit()
db.refresh(puck)
return {"status": "success", "puck_id": puck.id, "beamtime_id": beamtime_id}

View File

@ -568,6 +568,7 @@ class DewarBase(BaseModel):
tracking_number: str tracking_number: str
number_of_pucks: Optional[int] = None number_of_pucks: Optional[int] = None
number_of_samples: Optional[int] = None number_of_samples: Optional[int] = None
created_at: Optional[datetime] = None
status: str status: str
contact_id: Optional[int] contact_id: Optional[int]
return_address_id: Optional[int] return_address_id: Optional[int]
@ -584,6 +585,7 @@ class DewarCreate(DewarBase):
class Dewar(DewarBase): class Dewar(DewarBase):
id: int id: int
pgroups: str pgroups: str
created_at: Optional[datetime] = None
shipment_id: Optional[int] shipment_id: Optional[int]
contact: Optional[Contact] contact: Optional[Contact]
return_address: Optional[Address] return_address: Optional[Address]
@ -772,6 +774,18 @@ class PuckWithTellPosition(BaseModel):
from_attributes = True from_attributes = True
class PuckResponse(BaseModel):
id: int
puck_name: str
class DewarWithPucksResponse(BaseModel):
id: int
dewar_name: str
created_at: datetime
pucks: List[PuckResponse]
class Beamtime(BaseModel): class Beamtime(BaseModel):
id: int id: int
pgroups: str pgroups: str

View File

@ -4,12 +4,13 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid'; import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import '../styles/Calendar.css'; import '../styles/Calendar.css';
import { BeamtimesService, DewarsService, PucksService } from '../../openapi';
// Define colors for each beamline // Define colors for each beamline
const beamlineColors: { [key: string]: string } = { const beamlineColors: { [key: string]: string } = {
PXI: '#FF5733', X06SA: '#FF5733',
PXII: '#33FF57', X10SA: '#33FF57',
PXIII: '#3357FF', X06DA: '#3357FF',
Unknown: '#CCCCCC', // Gray color for unknown beamlines Unknown: '#CCCCCC', // Gray color for unknown beamlines
}; };
@ -38,8 +39,11 @@ const darkenColor = (color: string, percent: number): string => {
const Calendar: React.FC = () => { const Calendar: React.FC = () => {
const [events, setEvents] = useState<CustomEvent[]>([]); const [events, setEvents] = useState<CustomEvent[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [selectedEventId, setSelectedEventId] = useState<string | null>(null); const [selectedEventId, setSelectedEventId] = useState<string | null>(null);
const [eventDetails, setEventDetails] = useState<CustomEvent | null>(null); const [eventDetails, setEventDetails] = useState<CustomEvent | null>(null);
const [eventAssociations, setEventAssociations] = useState<{ [eventId: string]: {dewars: string[], pucks: string[]} }>({});
const [userDetails, setUserDetails] = useState({ const [userDetails, setUserDetails] = useState({
name: '', name: '',
firstName: '', firstName: '',
@ -51,76 +55,94 @@ const Calendar: React.FC = () => {
const [shipments, setShipments] = useState<any[]>([]); // State for shipments const [shipments, setShipments] = useState<any[]>([]); // State for shipments
const [selectedDewars, setSelectedDewars] = useState<string[]>([]); // Track selected dewars for the experiment const [selectedDewars, setSelectedDewars] = useState<string[]>([]); // Track selected dewars for the experiment
const fetchBeamtimes = async () => {
setIsLoading(true);
setFetchError(null);
try {
const beamtimeData = await BeamtimesService.getMyBeamtimesProtectedBeamtimesMyBeamtimesGet(); // Replace with actual API function
const formattedEvents: CustomEvent[] = beamtimeData.map((beamtime: any) => ({
id: `${beamtime.beamline}-${beamtime.start_date}`,
title: `${beamtime.beamline}: ${beamtime.shift}`,
start: beamtime.start_date,
end: beamtime.end_date,
beamtime_id: beamtime.id,
beamline: beamtime.beamline || 'Unknown',
beamtime_shift: beamtime.shift || 'Unknown',
backgroundColor: beamlineColors[beamtime.beamline] || beamlineColors.Unknown,
borderColor: '#000',
textColor: '#fff',
}));
setEvents(formattedEvents);
} catch (error) {
console.error('Failed to fetch beamtime data:', error);
setFetchError('Failed to load beamtime data. Please try again later.');
} finally {
setIsLoading(false);
}
};
// Fetch beamtimes on component mount
useEffect(() => { useEffect(() => {
const fetchEvents = async () => { fetchBeamtimes();
try {
const response = await fetch('/beamtimedb.json');
const data = await response.json();
const events: CustomEvent[] = [];
data.beamtimes.forEach((beamtime: any) => {
const date = new Date(beamtime.date);
beamtime.shifts.forEach((shift: any) => {
const beamline = shift.beamline || 'Unknown';
const beamtime_shift = shift.beamtime_shift || 'morning';
const event: CustomEvent = {
id: `${beamline}-${date.toISOString()}-${beamtime_shift}`,
start: new Date(date.setHours(0, 0, 0)),
end: new Date(date.setHours(23, 59, 59)),
title: `${beamline}: ${beamtime_shift}`,
beamline,
beamtime_shift,
isSubmitted: false,
};
events.push(event);
});
});
console.log('Fetched events array:', events);
setEvents(events);
} catch (error) {
console.error('Error fetching events:', error);
}
};
const fetchShipments = async () => {
try {
const response = await fetch('/shipmentdb.json');
// Check for HTTP errors
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Parse the JSON response
const data = await response.json();
const availableDewars: any[] = [];
data.shipments.forEach(shipment => {
if (shipment.shipment_status === "In Transit") {
shipment.dewars.forEach(dewar => {
if (dewar.shippingStatus === "shipped" && dewar.returned === "") {
availableDewars.push(dewar);
}
});
}
});
console.log('Available Dewars:', availableDewars);
setShipments(availableDewars);
} catch (error) {
console.error('Error fetching shipments:', error);
// Optionally display the error to the user in the UI
}
};
fetchEvents();
fetchShipments();
}, []); }, []);
useEffect(() => {
// Only fetch if you want to update dewars when eventDetails change,
// or run only on initial mount by passing [] as deps
if (eventDetails) {
const fetchDewars = async () => {
try {
// This name depends on your codegen's operationId / function name!
const dewars = await DewarsService.getRecentDewarsWithPucks();
setShipments(dewars);
} catch (err) {
// Optionally handle error
console.error("Failed to fetch dewars with pucks", err);
}
};
fetchDewars();
}
}, [eventDetails]); // Fetch dewars when an event is selected
//const fetchShipments = async () => {
// try {
// const response = await fetch('/shipmentdb.json');
//
// // Check for HTTP errors
// if (!response.ok) {
// throw new Error(`HTTP error! status: ${response.status}`);
// }
//
// // Parse the JSON response
// const data = await response.json();
//
// const availableDewars: any[] = [];
//
// data.shipments.forEach(shipment => {
// if (shipment.shipment_status === "In Transit") {
// shipment.dewars.forEach(dewar => {
// if (dewar.shippingStatus === "shipped" && dewar.returned === "") {
// availableDewars.push(dewar);
// }
// });
// }
// });
//
// console.log('Available Dewars:', availableDewars);
// setShipments(availableDewars);
// } catch (error) {
// console.error('Error fetching shipments:', error);
// // Optionally display the error to the user in the UI
// }
// };
//
// fetchEvents();
// fetchShipments();
//}, []);
const handleEventClick = (eventInfo: any) => { const handleEventClick = (eventInfo: any) => {
const clickedEventId = eventInfo.event.id; const clickedEventId = eventInfo.event.id;
setSelectedEventId(clickedEventId); setSelectedEventId(clickedEventId);
@ -137,15 +159,6 @@ const Calendar: React.FC = () => {
})); }));
}; };
const handleDewarSelection = (dewarId: string) => {
setSelectedDewars(prevSelectedDewars => {
if (prevSelectedDewars.includes(dewarId)) {
return prevSelectedDewars.filter(id => id !== dewarId); // Remove if already selected
} else {
return [...prevSelectedDewars, dewarId]; // Add if not selected
}
});
};
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -173,11 +186,151 @@ const Calendar: React.FC = () => {
}); });
setSelectedDewars([]); // Reset selected dewars setSelectedDewars([]); // Reset selected dewars
}; };
const assignDewarToBeamtime = async (dewarId: number, beamtimeId: number) => {
return DewarsService.assignDewarToBeamtime(dewarId, beamtimeId);
};
const unassignDewarFromBeamtime = async (dewarId: number) => {
// Pass "0" as the special value (if that's how you unassign)
return DewarsService.assignDewarToBeamtime(dewarId, 0); // or whatever your backend supports
};
const assignPuckToBeamtime = async (puckId: number, beamtimeId: number) => {
return PucksService.assignPuckToBeamtime(puckId, beamtimeId);
};
// For unassignment (beamtime_id = 0)
const unassignPuckFromBeamtime = async (puckId: number) => {
return PucksService.assignPuckToBeamtime(puckId, 0);
};
function handleDewarAssignment(dewarId: string) {
const event = events.find(e => e.id === selectedEventId);
if (!event) return;
const beamtimeId = event.beamtime_id;
if (beamtimeId == null) {
console.error("No numeric beamtime_id found for selected event:", event);
return;
}
// Find out if it's already assigned to this shift!
const prev = eventAssociations[selectedEventId] || { dewars: [], pucks: [] };
const isAssigned = prev.dewars.includes(dewarId);
if (!isAssigned) {
// Assign it immediately
assignDewarToBeamtime(Number(dewarId), Number(beamtimeId))
.then(() => {
setEventAssociations(prevAssoc => {
const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] };
return {
...prevAssoc,
[selectedEventId]: {
...updated,
dewars: [...updated.dewars, dewarId]
}
};
});
})
.catch(e => {
console.error("Failed to assign dewar to beamtime", e);
});
} else {
// Unassign (patch to None) immediately
unassignDewarFromBeamtime(Number(dewarId))
.then(() => {
setEventAssociations(prevAssoc => {
const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] };
return {
...prevAssoc,
[selectedEventId]: {
...updated,
dewars: updated.dewars.filter(id => id !== dewarId)
}
};
});
})
.catch(e => {
console.error("Failed to unassign dewar from beamtime", e);
});
}
}
function handlePuckAssignment(puckId: string) {
const event = events.find(e => e.id === selectedEventId);
if (!event) return;
const beamtimeId = event.beamtime_id;
if (beamtimeId == null) {
console.error("No numeric beamtime_id found for selected event:", event);
return;
}
// Find out if it's already assigned to this shift!
const prev = eventAssociations[selectedEventId] || { dewars: [], pucks: [] };
const isAssigned = prev.pucks.includes(puckId);
if (!isAssigned) {
// Assign it immediately
assignPuckToBeamtime(Number(puckId), Number(beamtimeId))
.then(() => {
setEventAssociations(prevAssoc => {
const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] };
return {
...prevAssoc,
[selectedEventId]: {
...updated,
pucks: [...updated.pucks, puckId]
}
};
});
})
.catch(e => {
console.error("Failed to assign puck to beamtime", e);
});
} else {
// Unassign (patch to None) immediately
unassignPuckFromBeamtime(Number(puckId))
.then(() => {
setEventAssociations(prevAssoc => {
const updated = prevAssoc[selectedEventId] || { dewars: [], pucks: [] };
return {
...prevAssoc,
[selectedEventId]: {
...updated,
pucks: updated.pucks.filter(id => id !== puckId)
}
};
});
})
.catch(e => {
console.error("Failed to unassign puck from beamtime", e);
});
}
}
function findAssociatedEventForDewar(dewarId: string): string | undefined {
for (const [eventId, assoc] of Object.entries(eventAssociations)) {
if (assoc.dewars.includes(dewarId)) return eventId;
}
}
function findAssociatedEventForPuck(puckId: string): string | undefined {
for (const [eventId, assoc] of Object.entries(eventAssociations)) {
if (assoc.pucks.includes(puckId)) return eventId;
}
}
const eventContent = (eventInfo: any) => { const eventContent = (eventInfo: any) => {
const beamline = eventInfo.event.extendedProps.beamline || 'Unknown'; const beamline = eventInfo.event.extendedProps.beamline || 'Unknown';
const isSelected = selectedEventId === eventInfo.event.id; const isSelected = selectedEventId === eventInfo.event.id;
const isSubmitted = eventInfo.event.extendedProps.isSubmitted; const isSubmitted = eventInfo.event.extendedProps.isSubmitted;
const hasAssociations =
eventAssociations[eventInfo.event.id]?.dewars.length > 0 ||
eventAssociations[eventInfo.event.id]?.pucks.length > 0;
const backgroundColor = isSubmitted const backgroundColor = isSubmitted
? darkenColor(beamlineColors[beamline] || beamlineColors.Unknown, -20) ? darkenColor(beamlineColors[beamline] || beamlineColors.Unknown, -20)
@ -203,6 +356,7 @@ const Calendar: React.FC = () => {
}} }}
> >
{eventInfo.event.title} {eventInfo.event.title}
{hasAssociations && <span style={{marginLeft: 8}}>🧊</span>}
</div> </div>
); );
}; };
@ -232,17 +386,64 @@ const Calendar: React.FC = () => {
<h4>Select Dewars</h4> <h4>Select Dewars</h4>
<ul> <ul>
{shipments.map(dewar => ( {shipments.map(dewar => {
<li key={dewar.id}> // Are *all* pucks assigned to this event? (Assigned at Dewar level)
<input const thisEvent = eventAssociations[selectedEventId] || {dewars: [], pucks: []};
type="checkbox" const dewarAssigned = thisEvent.dewars.includes(dewar.id);
id={dewar.id} const puckAssignments = thisEvent.pucks;
checked={selectedDewars.includes(dewar.id)}
onChange={() => handleDewarSelection(dewar.id)} return (
/> <li key={dewar.id}>
<label htmlFor={dewar.id}>{dewar.dewar_name} (Pucks: {dewar.number_of_pucks})</label> <label>
</li> <input
))} type="checkbox"
checked={dewarAssigned}
onChange={() => handleDewarAssignment(dewar.id)}
/>
<b>{dewar.dewar_name}</b>
</label>
{!dewarAssigned && dewar.pucks && dewar.pucks.length > 0 && (
<ul>
{(dewar.pucks || []).map(puck => {
const isAssigned =
!!eventAssociations[selectedEventId]?.pucks.includes(puck.id);
return (
<div key={puck.id} style={{ display: 'flex', alignItems: 'center' }}>
<span>{puck.name}</span>
<button
type="button"
style={{
marginLeft: 8,
background: isAssigned ? '#4CAF50' : '#e0e0e0',
color: isAssigned ? 'white' : 'black',
border: isAssigned ? '1px solid #388e3c' : '1px solid #bdbdbd',
borderRadius: 4,
padding: '4px 10px',
cursor: 'pointer',
transition: 'background 0.2s',
}}
onClick={() => handlePuckAssignment(dewar.id, puck.id)}
>
{isAssigned ? puck.puck_name : puck.puck_name}
</button>
{/* Optionally show assignment info, as before */}
{findAssociatedEventForPuck(puck.id) && (
<span style={{marginLeft: 8, color: 'green'}}> Assigned to shift: {findAssociatedEventForPuck(puck.id)}</span>
)}
</div>
);
})}
</ul>
)}
{/* Show associated shift, if any */}
{findAssociatedEventForDewar(dewar.id) && (
<span style={{marginLeft: 8, color: 'green'}}> Assigned to shift: {findAssociatedEventForDewar(dewar.id)}</span>
)}
</li>
);
})}
</ul> </ul>
<h4>User Information</h4> <h4>User Information</h4>

View File

@ -1,46 +1,17 @@
.calendar-container { .fc-event-shift {
width: 80%; position: absolute !important; /* Enables proper alignment */
margin: 0 auto; font-size: 12px; /* Text size for better clarity */
} line-height: 1.2; /* Improve readability */
height: auto !important; /* Flexible height based on content */
/* Styling each day cell */ min-height: 25px; /* Ensure adequate space vertically */
.fc-daygrid-day-frame { width: 28% !important; /* Prevent events from spanning full cell width */
position: relative; /* Ensure positioning for child elements */ border: 1px solid #555; /* Consistent event border */
border: 1px solid #e0e0e0; /* Grid cell border for better visibility */ border-radius: 4px; /* Rounded corners */
} background-color: rgba(255, 255, 255, 0.9); /* Default background */
white-space: nowrap; /* Prevent text wrapping */
/* Event styling */ overflow: hidden; /* Hide overflowing content */
.fc-event { text-overflow: ellipsis; /* Show '...' for long titles */
border-radius: 3px; /* Rounded corners for events */ display: flex; /* Align content vertically and horizontally */
padding: 4px; /* Padding for events */ justify-content: center; /* Center horizontal alignment */
font-size: 12px; /* Font size for event text */ align-items: center; /* Center vertical alignment */
cursor: pointer; /* Pointer cursor for events */ }
box-sizing: border-box; /* Include padding in the width/height */
}
/* Selected event styling */
.fc-event-selected {
border: 2px solid black; /* Border for selected events */
}
/* Optional: Add hover effect for events */
.fc-event:hover {
background-color: #FF7043; /* Change color on hover */
}
.event-details {
margin-top: 20px;
padding: 15px;
border: 1px solid #ccc;
border-radius: 5px;
background-color: #f9f9f9;
}
.event-details h3 {
margin: 0 0 10px;
}
.event-details label {
display: block;
margin-bottom: 10px;
}