Add beamtime functionality to backend.

Introduce new endpoint and model for managing beamtimes, including shifts and user-specific access. Updated test scripts and data to reflect beamtime integration, along with minor fixes for job status enumeration and example notebook refinement.
This commit is contained in:
GotthardG 2025-05-05 16:05:37 +02:00
parent db6474c86a
commit 102a11eed7
9 changed files with 220 additions and 196 deletions

View File

@ -395,10 +395,11 @@ beamtimes = [
Beamtime(
id=1,
pgroups="p20001",
shift="morning",
beamtime_name="p20001-test",
beamline="X06DA",
start_date=datetime.strptime("06.02.2025", "%d.%m.%Y").date(),
end_date=datetime.strptime("07.02.2025", "%d.%m.%Y").date(),
start_date=datetime.strptime("06.05.2025", "%d.%m.%Y").date(),
end_date=datetime.strptime("06.05.2025", "%d.%m.%Y").date(),
status="confirmed",
comments="this is a test beamtime",
proposal_id=1,
@ -407,10 +408,11 @@ beamtimes = [
Beamtime(
id=2,
pgroups="p20002",
shift="afternoon",
beamtime_name="p20001-test",
beamline="X06DA",
start_date=datetime.strptime("07.02.2025", "%d.%m.%Y").date(),
end_date=datetime.strptime("08.02.2025", "%d.%m.%Y").date(),
start_date=datetime.strptime("07.05.2025", "%d.%m.%Y").date(),
end_date=datetime.strptime("08.05.2025", "%d.%m.%Y").date(),
status="confirmed",
comments="this is a test beamtime",
proposal_id=2,

View File

@ -8,6 +8,7 @@ from sqlalchemy import (
DateTime,
Boolean,
func,
Enum,
)
from sqlalchemy.orm import relationship
from .database import Base
@ -235,11 +236,15 @@ class PuckEvent(Base):
puck = relationship("Puck", back_populates="events")
SHIFT_CHOICES = ("morning", "afternoon", "night")
class Beamtime(Base):
__tablename__ = "beamtimes"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
pgroups = Column(String(255), nullable=False)
shift = Column(Enum(*SHIFT_CHOICES, name="shift_enum"), nullable=False, index=True)
beamtime_name = Column(String(255), index=True)
beamline = Column(String(255), nullable=True)
start_date = Column(Date, nullable=True)
@ -282,6 +287,7 @@ class Results(Base):
__tablename__ = "results"
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
status = Column(String(255), nullable=False)
result = Column(JSON, nullable=False) # store the full result object as JSON
sample_id = Column(Integer, ForeignKey("samples.id"), nullable=False)
run_id = Column(Integer, ForeignKey("experiment_parameters.id"), nullable=False)
@ -310,7 +316,7 @@ class Results(Base):
class JobStatus(str, enum.Enum):
TODO = "todo"
TO_DO = "to_do"
SUBMITTED = "submitted"
DONE = "done"
TO_CANCEL = "to_cancel"

View File

@ -0,0 +1,72 @@
from fastapi import APIRouter, HTTPException, status, Depends
from sqlalchemy.orm import Session
from sqlalchemy import or_
from app.models import Beamtime as BeamtimeModel
from app.schemas import Beamtime as BeamtimeSchema, BeamtimeCreate, loginData
from app.dependencies import get_db
from app.routers.auth import get_current_user
beamtime_router = APIRouter()
@beamtime_router.post("/", response_model=BeamtimeSchema)
async def create_beamtime(
beamtime: BeamtimeCreate,
db: Session = Depends(get_db),
current_user: loginData = Depends(get_current_user),
):
# Validate the pgroup belongs to the current user
if beamtime.pgroups not in current_user.pgroups:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="You do not have permission to create a beamtime for this pgroup.",
)
# Check for existing beamtime for this pgroup, date, and shift
existing = (
db.query(BeamtimeModel)
.filter(
BeamtimeModel.pgroups == beamtime.pgroups,
BeamtimeModel.start_date == beamtime.start_date,
BeamtimeModel.shift == beamtime.shift,
)
.first()
)
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A beamtime for this pgroup/shift/date already exists.",
)
db_beamtime = BeamtimeModel(
pgroups=beamtime.pgroups,
shift=beamtime.shift,
beamtime_name=beamtime.beamtime_name,
beamline=beamtime.beamline,
start_date=beamtime.start_date,
end_date=beamtime.end_date,
status=beamtime.status,
comments=beamtime.comments,
proposal_id=beamtime.proposal_id,
local_contact_id=beamtime.local_contact_id,
)
db.add(db_beamtime)
db.commit()
db.refresh(db_beamtime)
return db_beamtime
@beamtime_router.get(
"/my-beamtimes",
response_model=list[BeamtimeSchema],
)
async def get_my_beamtimes(
db: Session = Depends(get_db),
current_user: loginData = Depends(get_current_user),
):
user_pgroups = current_user.pgroups
filters = [BeamtimeModel.pgroups.like(f"%{pgroup}%") for pgroup in user_pgroups]
beamtimes = db.query(BeamtimeModel).filter(or_(*filters)).all()
return beamtimes

View File

@ -2,6 +2,7 @@ from fastapi import APIRouter, Depends
from app.routers.auth import get_current_user
from app.routers.address import address_router
from app.routers.beamtime import beamtime_router
from app.routers.contact import contact_router
from app.routers.shipment import shipment_router
from app.routers.dewar import dewar_router
@ -20,3 +21,6 @@ protected_router.include_router(
shipment_router, prefix="/shipments", tags=["shipments"]
)
protected_router.include_router(dewar_router, prefix="/dewars", tags=["dewars"])
protected_router.include_router(
beamtime_router, prefix="/beamtimes", tags=["beamtimes"]
)

View File

@ -399,7 +399,7 @@ def update_experiment_run_dataset(
sample_id=sample_id,
run_id=run_id,
experiment_parameters=exp, # adjust this line as appropriate
status=JobStatus.TODO,
status=JobStatus.TO_DO,
)
db.add(new_job)
db.commit()

View File

@ -772,6 +772,7 @@ class PuckWithTellPosition(BaseModel):
class Beamtime(BaseModel):
id: int
pgroups: str
shift: str
beamtime_name: str
beamline: str
start_date: date
@ -779,7 +780,6 @@ class Beamtime(BaseModel):
status: str
comments: Optional[constr(max_length=200)] = None
proposal_id: Optional[int]
proposal: Optional[Proposal]
local_contact_id: Optional[int]
local_contact: Optional[LocalContact]
@ -787,6 +787,19 @@ class Beamtime(BaseModel):
from_attributes = True
class BeamtimeCreate(BaseModel):
pgroups: str # this should be changed to pgroup
shift: str
beamtime_name: str
beamline: str
start_date: date
end_date: date
status: str
comments: Optional[constr(max_length=200)] = None
proposal_id: Optional[int]
local_contact_id: Optional[int]
class ImageCreate(BaseModel):
pgroup: str
sample_id: int
@ -940,6 +953,7 @@ class SampleResult(BaseModel):
class ResultCreate(BaseModel):
sample_id: int
status: str
run_id: int
result: Results
@ -949,6 +963,7 @@ class ResultCreate(BaseModel):
class ResultResponse(BaseModel):
id: int
status: str
sample_id: int
run_id: int
result: Results

View File

@ -13,14 +13,17 @@ services:
- ./app:/app/app # Map app directory to /app/app
- ./config_${ENVIRONMENT}.json:/app/backend/config_${ENVIRONMENT}.json # Explicitly map config_dev.json
- ./backend/ssl:/app/backend/ssl # clearly mount SSL files explicitly into Docker
- ./uploads:/app/backend/uploads
- ./uploads:/app/backend/images
working_dir: /app/backend # Set working directory to backend/
command: python main.py # Command to run main.py
depends_on: # ⬅️ New addition: wait until postgres is started
- postgres
healthcheck:
test: [ "CMD-SHELL", "curl -k -f https://localhost:${PORT}/openapi.json || exit 1" ]
interval: 1m
timeout: 10s
interval: 30s
timeout: 5s
retries: 5
environment: # ⬅️ Provide DB info to your backend
ENVIRONMENT: ${ENVIRONMENT}
@ -39,7 +42,7 @@ services:
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
- ./db_data:/var/lib/postgresql/data
frontend:

View File

@ -5,7 +5,7 @@ import './SampleImage.css';
import './ResultGrid.css';
import { OpenAPI, SamplesService } from '../../openapi';
import ScheduleIcon from '@mui/icons-material/Schedule';
import AutorenewIcon from '@mui/icons-material/Autorenew';
import DoDisturbIcon from '@mui/icons-material/DoDisturb';
import TaskAltIcon from '@mui/icons-material/TaskAlt';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
@ -152,6 +152,8 @@ const ResultGrid: React.FC<ResultGridProps> = ({ activePgroup }) => {
);
case 'failed':
return <ErrorOutlineIcon color="error" titleAccess="Failed" />;
case 'cancelled':
return <DoDisturbIcon color="disabled" titleAccess="Cancelled" />;
case 'no job':
default:
return <InfoOutlinedIcon color="disabled" titleAccess="No job" />;

File diff suppressed because one or more lines are too long