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)
pgroups = 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_serial_number_id = Column(
Integer, ForeignKey("dewar_serial_numbers.id"), nullable=True

View File

@ -1,6 +1,7 @@
import os
import tempfile
import time
from datetime import datetime, timedelta
import random
import hashlib
from fastapi import APIRouter, HTTPException, status, Depends, Response
@ -21,7 +22,10 @@ from app.schemas import (
Sample,
Puck,
SampleEventResponse,
DewarSchema, # Clearer name for schema
DewarSchema,
loginData,
DewarWithPucksResponse,
PuckResponse,
)
from app.models import (
Dewar as DewarModel,
@ -44,6 +48,7 @@ from reportlab.pdfgen import canvas
from app.crud import (
get_shipment_by_id,
)
from app.routers.auth import get_current_user
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")
@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)
async def get_dewar(dewar_id: int, db: Session = Depends(get_db)):
dewar = (

View File

@ -658,3 +658,21 @@ async def get_pucks_by_slot(slot_identifier: str, db: Session = Depends(get_db))
)
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
number_of_pucks: Optional[int] = None
number_of_samples: Optional[int] = None
created_at: Optional[datetime] = None
status: str
contact_id: Optional[int]
return_address_id: Optional[int]
@ -584,6 +585,7 @@ class DewarCreate(DewarBase):
class Dewar(DewarBase):
id: int
pgroups: str
created_at: Optional[datetime] = None
shipment_id: Optional[int]
contact: Optional[Contact]
return_address: Optional[Address]
@ -772,6 +774,18 @@ class PuckWithTellPosition(BaseModel):
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):
id: int
pgroups: str

View File

@ -4,12 +4,13 @@ import dayGridPlugin from '@fullcalendar/daygrid';
import timeGridPlugin from '@fullcalendar/timegrid';
import interactionPlugin from '@fullcalendar/interaction';
import '../styles/Calendar.css';
import { BeamtimesService, DewarsService, PucksService } from '../../openapi';
// Define colors for each beamline
const beamlineColors: { [key: string]: string } = {
PXI: '#FF5733',
PXII: '#33FF57',
PXIII: '#3357FF',
X06SA: '#FF5733',
X10SA: '#33FF57',
X06DA: '#3357FF',
Unknown: '#CCCCCC', // Gray color for unknown beamlines
};
@ -38,8 +39,11 @@ const darkenColor = (color: string, percent: number): string => {
const Calendar: React.FC = () => {
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 [eventDetails, setEventDetails] = useState<CustomEvent | null>(null);
const [eventAssociations, setEventAssociations] = useState<{ [eventId: string]: {dewars: string[], pucks: string[]} }>({});
const [userDetails, setUserDetails] = useState({
name: '',
firstName: '',
@ -51,76 +55,94 @@ const Calendar: React.FC = () => {
const [shipments, setShipments] = useState<any[]>([]); // State for shipments
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(() => {
const fetchEvents = async () => {
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();
fetchBeamtimes();
}, []);
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 clickedEventId = eventInfo.event.id;
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) => {
e.preventDefault();
@ -173,11 +186,151 @@ const Calendar: React.FC = () => {
});
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 beamline = eventInfo.event.extendedProps.beamline || 'Unknown';
const isSelected = selectedEventId === eventInfo.event.id;
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
? darkenColor(beamlineColors[beamline] || beamlineColors.Unknown, -20)
@ -203,6 +356,7 @@ const Calendar: React.FC = () => {
}}
>
{eventInfo.event.title}
{hasAssociations && <span style={{marginLeft: 8}}>🧊</span>}
</div>
);
};
@ -232,17 +386,64 @@ const Calendar: React.FC = () => {
<h4>Select Dewars</h4>
<ul>
{shipments.map(dewar => (
<li key={dewar.id}>
<input
type="checkbox"
id={dewar.id}
checked={selectedDewars.includes(dewar.id)}
onChange={() => handleDewarSelection(dewar.id)}
/>
<label htmlFor={dewar.id}>{dewar.dewar_name} (Pucks: {dewar.number_of_pucks})</label>
</li>
))}
{shipments.map(dewar => {
// Are *all* pucks assigned to this event? (Assigned at Dewar level)
const thisEvent = eventAssociations[selectedEventId] || {dewars: [], pucks: []};
const dewarAssigned = thisEvent.dewars.includes(dewar.id);
const puckAssignments = thisEvent.pucks;
return (
<li key={dewar.id}>
<label>
<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>
<h4>User Information</h4>

View File

@ -1,46 +1,17 @@
.calendar-container {
width: 80%;
margin: 0 auto;
}
/* Styling each day cell */
.fc-daygrid-day-frame {
position: relative; /* Ensure positioning for child elements */
border: 1px solid #e0e0e0; /* Grid cell border for better visibility */
}
/* Event styling */
.fc-event {
border-radius: 3px; /* Rounded corners for events */
padding: 4px; /* Padding for events */
font-size: 12px; /* Font size for event text */
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;
}
.fc-event-shift {
position: absolute !important; /* Enables proper alignment */
font-size: 12px; /* Text size for better clarity */
line-height: 1.2; /* Improve readability */
height: auto !important; /* Flexible height based on content */
min-height: 25px; /* Ensure adequate space vertically */
width: 28% !important; /* Prevent events from spanning full cell width */
border: 1px solid #555; /* Consistent event border */
border-radius: 4px; /* Rounded corners */
background-color: rgba(255, 255, 255, 0.9); /* Default background */
white-space: nowrap; /* Prevent text wrapping */
overflow: hidden; /* Hide overflowing content */
text-overflow: ellipsis; /* Show '...' for long titles */
display: flex; /* Align content vertically and horizontally */
justify-content: center; /* Center horizontal alignment */
align-items: center; /* Center vertical alignment */
}