From a91d74b718e097a52b83220971ffcad44245d68f Mon Sep 17 00:00:00 2001 From: GotthardG <51994228+GotthardG@users.noreply.github.com> Date: Mon, 18 Nov 2024 14:22:54 +0100 Subject: [PATCH] https and ssl integration on the backend, frontend and started integration of logistics app as a separate frontend --- .gitignore | 1 + backend/app/main.py | 16 +++- backend/app/routers/logistics.py | 100 ++++++++++++++++------- backend/app/ssl_heidi.py | 52 ++++++++++++ frontend/src/components/ShipmentForm.tsx | 2 +- frontend/src/components/UploadDialog.tsx | 2 +- frontend/src/pages/ShipmentView.tsx | 2 +- frontend/vite.config.ts | 21 +++-- logistics/src/components/Slots.tsx | 18 ++-- logistics/src/components/Storage.tsx | 7 +- requirements.txt | 5 ++ 11 files changed, 176 insertions(+), 50 deletions(-) create mode 100644 .gitignore create mode 100644 backend/app/ssl_heidi.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d81b5ac --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/frontend/.env.local diff --git a/backend/app/main.py b/backend/app/main.py index 058b194..1e81c49 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -2,12 +2,20 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware +from app import ssl_heidi +from pathlib import Path + from app.routers import address, contact, proposal, dewar, shipment, puck, spreadsheet, logistics from app.database import Base, engine, SessionLocal, load_sample_data app = FastAPI() +# Generate SSL Key and Certificate if not exist +Path("ssl").mkdir(parents=True, exist_ok=True) +if not Path("ssl/cert.pem").exists() or not Path("ssl/key.pem").exists(): + ssl_heidi.generate_self_signed_cert("ssl/cert.pem", "ssl/key.pem") + # Apply CORS middleware app.add_middleware( CORSMiddleware, @@ -39,9 +47,11 @@ app.include_router(dewar.router, prefix="/dewars", tags=["dewars"]) app.include_router(shipment.router, prefix="/shipments", tags=["shipments"]) app.include_router(puck.router, prefix="/pucks", tags=["pucks"]) app.include_router(spreadsheet.router, tags=["spreadsheet"]) -app.include_router(dewar.router, prefix="/logistics", tags=["logistics"]) +app.include_router(logistics.router, prefix="/logistics", tags=["logistics"]) + if __name__ == "__main__": - import uvicorn + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain(certfile="ssl/cert.pem", keyfile="ssl/key.pem") - uvicorn.run(app, host="127.0.0.1", port=8000, log_level="debug") + uvicorn.run(app, host="127.0.0.1", port=8000, log_level="debug", ssl_context=ssl_context) diff --git a/backend/app/routers/logistics.py b/backend/app/routers/logistics.py index 8c80501..6680488 100644 --- a/backend/app/routers/logistics.py +++ b/backend/app/routers/logistics.py @@ -1,56 +1,96 @@ from fastapi import APIRouter, HTTPException, Depends from sqlalchemy.orm import Session +import logging from datetime import datetime, timedelta from typing import List - from app.models import Dewar as DewarModel, Slot as SlotModel, LogisticsEvent as LogisticsEventModel -from app.schemas import LogisticsEventCreate, SlotCreate, Slot as SlotSchema +from app.schemas import LogisticsEventCreate, SlotCreate, Slot as SlotSchema, Dewar as DewarSchema from app.database import get_db router = APIRouter() -@router.post("/scan-dewar") +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +@router.get("/dewars", response_model=List[DewarSchema]) +async def get_all_prouts(db: Session = Depends(get_db)): + try: + dewars = db.query(DewarModel).all() + logging.info(f"Retrieved {len(dewars)} dewars from the database") + return dewars + except Exception as e: + logger.error(f"An error occurred: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + +@router.get("/dewar/{qr_code}", response_model=DewarSchema) +async def get_dewar_by_qr_code(qr_code: str, db: Session = Depends(get_db)): + logger.info(f"Received qr_code: {qr_code}") + + trimmed_qr_code = qr_code.strip() + logger.info(f"Trimmed qr_code after stripping: {trimmed_qr_code}") + + try: + dewar = db.query(DewarModel).filter(DewarModel.unique_id == trimmed_qr_code).first() + logger.info(f"Query Result: {dewar}") + + if dewar: + return dewar + else: + logger.error(f"Dewar not found for unique_id: {trimmed_qr_code}") + raise HTTPException(status_code=404, detail="Dewar not found") + except Exception as e: + logger.error(f"An error occurred: {e}") + raise HTTPException(status_code=500, detail="Internal server error") + +@router.post("/dewar/scan", response_model=LogisticsEventCreate) async def scan_dewar(event_data: LogisticsEventCreate, db: Session = Depends(get_db)): dewar_qr_code = event_data.dewar_qr_code location_qr_code = event_data.location_qr_code transaction_type = event_data.transaction_type - dewar = db.query(DewarModel).filter(DewarModel.unique_id == dewar_qr_code).first() - if not dewar: - raise HTTPException(status_code=404, detail="Dewar not found") + try: + dewar = db.query(DewarModel).filter(DewarModel.unique_id == dewar_qr_code).first() + if not dewar: + raise HTTPException(status_code=404, detail="Dewar not found") - if transaction_type == 'incoming': - slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() - if not slot or slot.occupied: - raise HTTPException(status_code=404, detail="Slot not found or already occupied") - slot.occupied = True - log_event(db, dewar.id, slot.id, 'incoming') + if transaction_type == 'incoming': + slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() + if not slot or slot.occupied: + raise HTTPException(status_code=404, detail="Slot not found or already occupied") + slot.occupied = True + log_event(db, dewar.id, slot.id, 'incoming') - elif transaction_type == 'beamline': - log_event(db, dewar.id, None, 'beamline') + elif transaction_type == 'beamline': + log_event(db, dewar.id, None, 'beamline') - elif transaction_type == 'outgoing': - slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() - if not slot or not slot.occupied: - raise HTTPException(status_code=404, detail="Slot not found or not occupied") - slot.occupied = False - log_event(db, dewar.id, slot.id, 'outgoing') + elif transaction_type == 'outgoing': + slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() + if not slot or not slot.occupied: + raise HTTPException(status_code=404, detail="Slot not found or not occupied") + slot.occupied = False + log_event(db, dewar.id, slot.id, 'outgoing') - elif transaction_type == 'release': - slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() - if not slot or not slot.occupied: - raise HTTPException(status_code=404, detail="Slot not found or not occupied") - slot.occupied = False - log_event(db, dewar.id, slot.id, 'released') + elif transaction_type == 'release': + slot = db.query(SlotModel).filter(SlotModel.id == location_qr_code).first() + if not slot or not slot.occupied: + raise HTTPException(status_code=404, detail="Slot not found or not occupied") + slot.occupied = False + log_event(db, dewar.id, slot.id, 'released') - db.commit() - return {"message": "Status updated successfully"} + db.commit() + logger.info(f"Status updated successfully for Dewar ID: {dewar.id}") + return {"message": "Status updated successfully"} + except Exception as e: + logger.error(f"An error occurred: {e}") + raise HTTPException(status_code=500, detail="Internal server error") def log_event(db: Session, dewar_id: int, slot_id: int, event_type: str): - new_event = LogisticsEventModel(dewar_id=dewar_id, slot_id=slot_id, event_type=event_type) + new_event = LogisticsEventModel(dewar_id=dewar_id if dewar_id else None, slot_id=slot_id, event_type=event_type) db.add(new_event) + db.commit() + logger.info(f"Logged event {event_type} for Dewar ID: {dewar_id}") -@router.get("/refill-status", response_model=List[SlotSchema]) +@router.get("/slots/refill-status", response_model=List[SlotSchema]) async def refill_status(db: Session = Depends(get_db)): slots_needing_refill = db.query(SlotModel).filter(SlotModel.needs_refill == True).all() result = [] diff --git a/backend/app/ssl_heidi.py b/backend/app/ssl_heidi.py new file mode 100644 index 0000000..3131b98 --- /dev/null +++ b/backend/app/ssl_heidi.py @@ -0,0 +1,52 @@ +# Generate SSL Key and Certificate +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import serialization, hashes +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives.serialization import Encoding +import datetime + + +def generate_self_signed_cert(cert_file: str, key_file: str): + # Generate private key + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + # Write private key to file + with open(key_file, "wb") as f: + f.write(key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + )) + + # Generate self-signed certificate + subject = issuer = x509.Name([ + x509.NameAttribute(NameOID.COUNTRY_NAME, u"CH"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"Argau"), + x509.NameAttribute(NameOID.LOCALITY_NAME, u"Villigen"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"Paul Scherrer Institut"), + x509.NameAttribute(NameOID.COMMON_NAME, u"PSI.CH"), + ]) + cert = x509.CertificateBuilder().subject_name( + subject + ).issuer_name( + issuer + ).public_key( + key.public_key() + ).serial_number( + x509.random_serial_number() + ).not_valid_before( + datetime.datetime.utcnow() + ).not_valid_after( + # Our certificate will be valid for 10 days + datetime.datetime.utcnow() + datetime.timedelta(days=10) + ).add_extension( + x509.SubjectAlternativeName([x509.DNSName(u"localhost")]), + critical=False, + ).sign(key, hashes.SHA256()) + + # Write certificate to file + with open(cert_file, "wb") as f: + f.write(cert.public_bytes(Encoding.PEM)) \ No newline at end of file diff --git a/frontend/src/components/ShipmentForm.tsx b/frontend/src/components/ShipmentForm.tsx index 9e69d23..ba2b56b 100644 --- a/frontend/src/components/ShipmentForm.tsx +++ b/frontend/src/components/ShipmentForm.tsx @@ -39,7 +39,7 @@ const ShipmentForm: React.FC = ({ sx = {}, onCancel, refreshS const [errorMessage, setErrorMessage] = React.useState(null); useEffect(() => { - OpenAPI.BASE = 'http://127.0.0.1:8000'; + OpenAPI.BASE = 'https://127.0.0.1:8000'; const getContacts = async () => { try { diff --git a/frontend/src/components/UploadDialog.tsx b/frontend/src/components/UploadDialog.tsx index e0c545a..dd9f3cb 100644 --- a/frontend/src/components/UploadDialog.tsx +++ b/frontend/src/components/UploadDialog.tsx @@ -46,7 +46,7 @@ const UploadDialog: React.FC = ({ open, onClose, selectedShip const fileInputRef = useRef(null); useEffect(() => { - OpenAPI.BASE = 'http://127.0.0.1:8000'; + OpenAPI.BASE = 'https://127.0.0.1:8000'; }, []); const handleFileUpload = async (event: React.ChangeEvent) => { diff --git a/frontend/src/pages/ShipmentView.tsx b/frontend/src/pages/ShipmentView.tsx index 0cb9f76..6265107 100644 --- a/frontend/src/pages/ShipmentView.tsx +++ b/frontend/src/pages/ShipmentView.tsx @@ -9,7 +9,7 @@ import { Grid, Container } from '@mui/material'; // Define props for Shipments View type ShipmentViewProps = React.PropsWithChildren>; -const API_BASE_URL = 'http://127.0.0.1:8000'; +const API_BASE_URL = 'https://127.0.0.1:8000'; OpenAPI.BASE = API_BASE_URL; const ShipmentView: React.FC = () => { diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8b96686..027bdb4 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,18 @@ -import { defineConfig } from 'vite'; +import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; -export default defineConfig({ - plugins: [ - react(), - ], +import path from 'path'; + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ''); + + return { + plugins: [ + react(), + ], + server: { + https: { + key: env.VITE_SSL_KEY_PATH, + cert: env.VITE_SSL_CERT_PATH, + } + }} }); \ No newline at end of file diff --git a/logistics/src/components/Slots.tsx b/logistics/src/components/Slots.tsx index 7ce78e3..359690c 100644 --- a/logistics/src/components/Slots.tsx +++ b/logistics/src/components/Slots.tsx @@ -16,12 +16,12 @@ export interface SlotData { timeUntilRefill: string; } -const SlotContainer = styled(Box)<{ occupied: boolean, isSelected: boolean }>` +const SlotContainer = styled(Box)<{ isOccupied: boolean, isSelected: boolean }>` width: 90px; height: 180px; margin: 10px; - background-color: ${(props) => (props.occupied ? '#f0f0f0' : '#ffffff')}; - border: ${(props) => (props.isSelected ? '3px solid blue' : '2px solid #aaaaaa')}; + background-color: ${({ isOccupied }) => (isOccupied ? '#ffebee' : '#e8f5e9')}; /* occupied = light red, free = light green */ + border: ${({ isSelected }) => (isSelected ? '3px solid blue' : '2px solid #aaaaaa')}; border-radius: 5px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); display: flex; @@ -56,14 +56,16 @@ const ClockIcon = styled(AccessTime)` `; const Slot: React.FC = ({ data, onSelect, isSelected }) => { + const { id, occupied, needsRefill, timeUntilRefill } = data; + return ( - onSelect(data)} isSelected={isSelected}> - {data.id} + onSelect(data)} isSelected={isSelected}> + {id} - {data.occupied && ( + {occupied && ( <> - {data.needsRefill && } - + {needsRefill && } + )} diff --git a/logistics/src/components/Storage.tsx b/logistics/src/components/Storage.tsx index 02b8e5c..cb8d90e 100644 --- a/logistics/src/components/Storage.tsx +++ b/logistics/src/components/Storage.tsx @@ -96,7 +96,12 @@ const Storage: React.FC = ({ name, selectedSlot }) => { {name} Slots {storageSlotsData[name].map((slot) => ( - + ))} {highlightedSlot && ( diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..1e8dbb4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +SQLAlchemy~=2.0.36 +fastapi~=0.115.4 +pydantic~=2.9.2 +openpyxl~=3.1.5 +typing_extensions~=4.12.2 \ No newline at end of file