From 03d5363932b07dc1d36a4065453d6cfe741086ad Mon Sep 17 00:00:00 2001 From: Julian Lobbes Date: Sat, 13 May 2023 02:54:04 +0200 Subject: [PATCH] feat(backend): devices CRUD and routes refactor --- backend/config.py | 6 ++- backend/crud/devices.py | 68 ++++++++++++++++++++++++++++++++++ backend/crud/users.py | 16 ++++---- backend/database/engine.py | 8 ++++ backend/exceptions.py | 13 +++++++ backend/main.py | 75 ++++++++++++-------------------------- backend/routes/__init__.py | 0 backend/routes/devices.py | 52 ++++++++++++++++++++++++++ backend/routes/users.py | 58 +++++++++++++++++++++++++++++ backend/schemas/devices.py | 33 ++++++++++++++--- 10 files changed, 263 insertions(+), 66 deletions(-) create mode 100644 backend/crud/devices.py create mode 100644 backend/exceptions.py create mode 100644 backend/routes/__init__.py create mode 100644 backend/routes/devices.py create mode 100644 backend/routes/users.py diff --git a/backend/config.py b/backend/config.py index 70bf0a1..9ef0e3f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -19,7 +19,11 @@ class Settings(BaseSettings): """ app_name: str = os.getenv("APP_NAME", "MEDWingS") - admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com") + app_version: str = os.getenv("APP_VERSION", "Unspecified") + + contact_name: str = os.getenv("CONTACT_NAME", "MEDWingS Development Team") + contact_email: str = os.getenv("CONTACT_EMAIL", "admin@example.com") + contact_url: str = os.getenv("CONTACT_URL", "https://www.example.com") # Debug mode has the following effects: # - logs SQL operations diff --git a/backend/crud/devices.py b/backend/crud/devices.py new file mode 100644 index 0000000..a44160d --- /dev/null +++ b/backend/crud/devices.py @@ -0,0 +1,68 @@ +"""This module handles CRUD operations for users in the database, based on pydanctic schemas.""" + +from datetime import datetime + +from sqlalchemy.orm import Session + +from backend.models import devices as devicemodel +from backend.models import users as usermodel +from backend.schemas import devices as deviceschema +from backend.exceptions import DataIntegrityException, NotFoundException + + +def create_device(db: Session, device: deviceschema.DeviceCreate) -> deviceschema.Device: + """Creates the specified device in the database.""" + + db_user = db.query(usermodel.User).filter(usermodel.User.id == device.owner_id).first() + if not db_user: + raise NotFoundException("Attempted to create a device for a nonexistent user.") + if not db_user.patient: + raise DataIntegrityException("Attempted to create a device for a user who is not a patient.") + + db_device = devicemodel.Device( + model_id=device.model_id, + owner_id=device.owner_id, + ) + db.add(db_device) + db.commit() + db.refresh(db_device) + + return deviceschema.Device.from_orm(db_device) + + +def read_device(db: Session, id: int) -> deviceschema.Device | None: + """Queries the db for a device with the specified id and returns it.""" + + db_device = db.query(devicemodel.Device).filter(devicemodel.Device.id == id).first() + if not db_device: + raise NotFoundException(f"Device with id '{id}' was not found.") + + return deviceschema.Device.from_orm(db_device) + + +def update_device(db: Session, device: deviceschema.DeviceUpdate, id: int) -> deviceschema.Device: + """Updates the specified device's last seen time.""" + + db_device = db.query(devicemodel.Device).filter(devicemodel.Device.id == id).first() + if not db_device: + raise NotFoundException(f"Device with id '{id}' was not found.") + + db_device.last_seen = device.last_seen + db.commit() + db.refresh(db_device) + + return deviceschema.Device.from_orm(db_device) + + +def delete_device(db: Session, id: int) -> deviceschema.Device: + """Deletes the user with the provided id from the db.""" + + db_device = db.query(devicemodel.Device).filter(devicemodel.Device.id == id).first() + if not db_device: + raise NotFoundException(f"Device with id '{id}' was not found.") + device_copy = deviceschema.Device.from_orm(db_device) + + db.delete(db_device) + db.commit() + + return device_copy diff --git a/backend/crud/users.py b/backend/crud/users.py index ad2f4b2..b7b67c6 100644 --- a/backend/crud/users.py +++ b/backend/crud/users.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from backend.models import users as usermodel from backend.schemas import users as userschema +from backend.exceptions import NotFoundException def hash_password(password: str) -> str: @@ -33,6 +34,7 @@ def _fill_missing_user_fields(db_user: usermodel.User) -> userschema.User: full_user = userschema.User.from_orm(db_user) if db_user.patient: + full_user.devices = db_user.patient.devices full_user.gender = db_user.patient.gender full_user.date_of_birth = db_user.patient.date_of_birth full_user.is_patient = True @@ -54,7 +56,6 @@ def create_user(db: Session, user: userschema.UserCreate) -> userschema.User: password=hash_password(user.password), ) - # Add user to database if user.is_patient: db_patient = usermodel.Patient( user=db_user, @@ -70,27 +71,26 @@ def create_user(db: Session, user: userschema.UserCreate) -> userschema.User: db.commit() - # Construct the updated user to return db.refresh(db_user) return _fill_missing_user_fields(db_user) def read_user(db: Session, id: int) -> userschema.User | None: - """Queries the db for a user with the specified id and returns them if they exist.""" + """Queries the db for a user with the specified id and returns them.""" db_user = db.query(usermodel.User).filter(usermodel.User.id == id).first() if not db_user: - return None + raise NotFoundException(f"User with id '{id}' not found.") return _fill_missing_user_fields(db_user) def read_user_by_email(db: Session, email: str) -> userschema.User | None: - """Queries the db for a user with the specified email and returns them if they exist.""" + """Queries the db for a user with the specified email and returns them.""" db_user = db.query(usermodel.User).filter(usermodel.User.email == email).first() if not db_user: - return None + raise NotFoundException(f"User with email '{email}' not found.") return _fill_missing_user_fields(db_user) @@ -111,7 +111,7 @@ def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema db_user = db.query(usermodel.User).filter(usermodel.User.id == id).first() if not db_user: - raise RuntimeError("Query returned no user.") # should be checked by caller + raise NotFoundException(f"User with id '{id}' not found.") for key in ['gender', 'date_of_birth']: value = getattr(user, key) @@ -134,7 +134,7 @@ def delete_user(db: Session, id: int) -> userschema.User: db_user = db.query(usermodel.User).filter(usermodel.User.id == id).first() if not db_user: - raise RuntimeError("Query returned no user.") # should be checked by caller + raise NotFoundException(f"User with id '{id}' not found.") user_copy = _fill_missing_user_fields(db_user) db.delete(db_user) diff --git a/backend/database/engine.py b/backend/database/engine.py index 3ce5cc9..7b1aaa4 100644 --- a/backend/database/engine.py +++ b/backend/database/engine.py @@ -17,3 +17,11 @@ engine = create_engine(_pg_dsn, echo=s.debug_mode) SessionLocal = sessionmaker(engine) # SQLalchemy base model Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/backend/exceptions.py b/backend/exceptions.py new file mode 100644 index 0000000..8e15048 --- /dev/null +++ b/backend/exceptions.py @@ -0,0 +1,13 @@ +"""This module is a collection of project-wide exceptions.""" + + +class NotFoundException(Exception): + """Raised when a resource was unexpectedly not found.""" + + pass + + +class DataIntegrityException(Exception): + """Raised when a resource was unexpectedly not found.""" + + pass diff --git a/backend/main.py b/backend/main.py index a701baf..a114fe4 100644 --- a/backend/main.py +++ b/backend/main.py @@ -3,24 +3,33 @@ This module defines the API routes provided by the backend. """ -from fastapi import Depends, FastAPI, HTTPException -from sqlalchemy.orm import Session +from fastapi import FastAPI -import backend.models.users as usermodel -import backend.schemas.users as userschema -import backend.crud.users as usercrud -from backend.database.engine import SessionLocal +from backend import config +from backend.routes.devices import router as devices_router +from backend.routes.devices import tag_metadata as devices_tags +from backend.routes.users import router as users_router +from backend.routes.users import tag_metadata as users_tags -app = FastAPI() +s = config.get_settings() +app = FastAPI( + title=f"{s.app_name} backend API", + description=f"This is the backend server API for {s.app_name}, a remote patient monitoring and early warning system.", + version=f"{s.app_version}", + contact={ + "name": f"{s.contact_name}", + "email": f"{s.contact_email}", + "url": f"{s.contact_url}", + }, + openapi_tags=[ + users_tags, + devices_tags, + ], +) - -def get_db(): - db = SessionLocal() - try: - yield db - finally: - db.close() +app.include_router(devices_router) +app.include_router(users_router) @app.get("/hello/") @@ -28,41 +37,3 @@ def hello(): """Placeholder for a proper healthcheck endpoint.""" return "Hello World!" - - -@app.post("/users/", response_model=userschema.User) -def create_user(user: userschema.UserCreate, db: Session = Depends(get_db)): - existing_user = usercrud.read_user_by_email(db, email=user.email) - if existing_user: - raise HTTPException(status_code=400, detail="A user with this email address is already registered.") - return usercrud.create_user(db=db, user=user) - - -@app.get("/users/{id}", response_model=userschema.User) -def read_user(id: int, db: Session = Depends(get_db)): - user = usercrud.read_user(db=db, id=id) - if not user: - raise HTTPException(status_code=404, detail=f"No user with id '{id}' found.") - return user - - -@app.get("/users/", response_model=list[userschema.User]) -def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): - users = usercrud.read_users(db=db, skip=skip, limit=limit) - return users - - -@app.patch("/users/{id}", response_model=userschema.User) -def update_user(id: int, user: userschema.UserUpdate, db: Session = Depends(get_db)): - current_user = usercrud.read_user(db=db, id=id) - if not current_user: - raise HTTPException(status_code=404, detail=f"No user with id '{id}' found.") - return usercrud.update_user(db=db, user=user, id=id) - - -@app.delete("/users/{id}", response_model=userschema.User) -def delete_user(id: int, db: Session = Depends(get_db)): - user = usercrud.read_user(db=db, id=id) - if not user: - raise HTTPException(status_code=404, detail=f"No user with id '{id}' found.") - return usercrud.delete_user(db=db, id=id) diff --git a/backend/routes/__init__.py b/backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/routes/devices.py b/backend/routes/devices.py new file mode 100644 index 0000000..9fc780b --- /dev/null +++ b/backend/routes/devices.py @@ -0,0 +1,52 @@ +"""This module contains endpoints for operations related to devices.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from backend.database.engine import get_db +from backend.schemas import devices as deviceschema +import backend.crud.devices as devicecrud +from backend.exceptions import NotFoundException + + +router = APIRouter( + prefix="/devices", + tags=["devices"] +) + +tag_metadata = { + "name": "devices", + "description": "Operations related to devices." +} + + +@router.post("/", response_model=deviceschema.Device) +def create_device(device: deviceschema.DeviceCreate, db: Session = Depends(get_db)): + try: + return devicecrud.create_device(db, device) + except NotFoundException as e: + raise HTTPException(404, str(e)) + + +@router.get("/{id}", response_model=deviceschema.Device) +def read_device(id: int, db: Session = Depends(get_db)): + try: + return devicecrud.read_device(db, id) + except NotFoundException as e: + raise HTTPException(404, str(e)) + + +@router.patch("/{id}", response_model=deviceschema.Device) +def update_device(id: int, device: deviceschema.DeviceUpdate, db: Session = Depends(get_db)): + try: + return devicecrud.update_device(db, device, id) + except NotFoundException as e: + raise HTTPException(404, str(e)) + + +@router.delete("/{id}", response_model=deviceschema.Device) +def delete_device(id: int, db: Session = Depends(get_db)): + try: + return devicecrud.delete_device(db, id) + except NotFoundException as e: + raise HTTPException(404, str(e)) diff --git a/backend/routes/users.py b/backend/routes/users.py new file mode 100644 index 0000000..874fc9b --- /dev/null +++ b/backend/routes/users.py @@ -0,0 +1,58 @@ +"""This module contains endpoints for operations related to users.""" + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from backend.database.engine import get_db +import backend.schemas.users as userschema +import backend.crud.users as usercrud +from backend.exceptions import NotFoundException + + +router = APIRouter( + prefix="/users", + tags=["users"] +) + +tag_metadata = { + "name": "users", + "description": "Operations related to users." +} + + +@router.post("/users/", response_model=userschema.User) +def create_user(user: userschema.UserCreate, db: Session = Depends(get_db)): + existing_user = usercrud.read_user_by_email(db, email=user.email) + if existing_user: + raise HTTPException(400, "A user with this email address is already registered.") + return usercrud.create_user(db=db, user=user) + + +@router.get("/users/{id}", response_model=userschema.User) +def read_user(id: int, db: Session = Depends(get_db)): + try: + return usercrud.read_user(db=db, id=id) + except NotFoundException as e: + raise HTTPException(404, str(e)) + + +@router.get("/users/", response_model=list[userschema.User]) +def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + users = usercrud.read_users(db=db, skip=skip, limit=limit) + return users + + +@router.patch("/users/{id}", response_model=userschema.User) +def update_user(id: int, user: userschema.UserUpdate, db: Session = Depends(get_db)): + try: + return usercrud.update_user(db=db, user=user, id=id) + except NotFoundException as e: + raise HTTPException(404, str(e)) + + +@router.delete("/users/{id}", response_model=userschema.User) +def delete_user(id: int, db: Session = Depends(get_db)): + try: + return usercrud.delete_user(db=db, id=id) + except NotFoundException as e: + raise HTTPException(404, str(e)) diff --git a/backend/schemas/devices.py b/backend/schemas/devices.py index 78e474c..600de7d 100644 --- a/backend/schemas/devices.py +++ b/backend/schemas/devices.py @@ -1,6 +1,7 @@ """This module declareds the pydantic schema representation for devices.""" from datetime import datetime +from abc import ABC from pydantic import BaseModel, validator @@ -26,11 +27,32 @@ class DeviceModel(BaseModel): orm_mode = True -class Device(BaseModel): - id: int - added: datetime +class DeviceCreate(BaseModel): + """Device schema used for Device creation.""" + + owner_id: int + model_id: int + + @validator('model_id') + def assert_model_id_is_valid(cls, model_id): + if not 1 <= model_id <= 3: + raise ValueError("Model id is invalid.") + return model_id + + +class DeviceUpdate(BaseModel): + """Device schema used for Device updates.""" + last_seen: datetime + + +class Device(BaseModel): + """Device schema used for Device display.""" + + id: int model: DeviceModel + added: datetime + last_seen: datetime | None @validator('added') def assert_added_is_valid(cls, added): @@ -40,8 +62,9 @@ class Device(BaseModel): @validator('last_seen') def assert_last_seen_is_valid(cls, last_seen): - if last_seen >= datetime.now(last_seen.tzinfo): - raise ValueError("Date last seen cannot be in the future.") + if last_seen: + if last_seen >= datetime.now(last_seen.tzinfo): + raise ValueError("Date last seen cannot be in the future.") return last_seen class Config: