feat(backend): add database and api

This commit is contained in:
Julian Lobbes 2023-05-12 04:59:05 +02:00
parent 7c04c2ef87
commit 7f2f5eebbe
10 changed files with 452 additions and 3 deletions

4
.gitignore vendored
View File

@ -5,6 +5,10 @@
/.venv/
/node_modules/
# Local development database
/.postgres/
/.omnidb/
# Cache files and directories
*.pyc
__pycache__/

34
backend/config.py Normal file
View File

@ -0,0 +1,34 @@
import os
from urllib.parse import quote_plus as url_encode
from functools import lru_cache
from pydantic import BaseSettings, PostgresDsn
class Settings(BaseSettings):
"""Contains application-specific configuration values.
Reads the values from environment variables and falls back to default values
if the corresponding environment variable is unset.
"""
app_name: str = os.getenv("APP_NAME", "MEDWingS")
admin_email: str = os.getenv("ADMIN_EMAIL", "admin@example.com")
debug_mode: bool = False
if os.getenv("DEBUG_MODE", "false").lower() == "true":
debug_mode = True
_pg_hostname = os.getenv("POSTGRES_HOST", "db")
_pg_port = os.getenv("POSTGRES_PORT", "5432")
_pg_dbname = os.getenv("POSTGRES_DB", "medwings")
_pg_user = url_encode(os.getenv("POSTGRES_USER", "medwings"))
_pg_password = url_encode(os.getenv("POSTGRES_PASSWORD", "medwings"))
pg_dsn: PostgresDsn = f"postgresql://{_pg_user}:{_pg_password}@{_pg_hostname}:{_pg_port}/{_pg_dbname}"
@lru_cache
def get_settings() -> Settings:
"""Creates the settings once and returns a cached version on subsequent requests."""
return Settings()

114
backend/crud.py Normal file
View File

@ -0,0 +1,114 @@
import logging
from datetime import datetime
from sqlalchemy.orm import Session
from .import models, schemas
log = logging.getLogger()
def hash_password(password: str) -> str:
# TODO actually hash the password!
return password
def _fill_missing_user_fields(db_user: models.User) -> schemas.User:
full_user = schemas.User.from_orm(db_user)
if db_user.patient:
full_user.gender = db_user.patient.gender
full_user.date_of_birth = db_user.patient.date_of_birth
full_user.is_patient = True
full_user.is_admin = False
else:
full_user.is_patient = False
full_user.is_admin = True
return full_user
def create_user(db: Session, user: schemas.UserCreate):
"""Creates a new user as either a patient or an administrator."""
db_user = models.User(
email=user.email,
first_name=user.first_name,
last_name=user.last_name,
password=hash_password(user.password),
)
# Add user to database
if user.is_patient:
db_patient = models.Patient(
user=db_user,
gender=user.gender,
date_of_birth=user.date_of_birth,
)
db.add(db_patient)
else:
db_administrator = models.Administrator(
user=db_user,
)
db.add(db_administrator)
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):
db_user = db.query(models.User).filter(models.User.id == id).first()
if not db_user:
return None
return _fill_missing_user_fields(db_user)
def read_user_by_email(db: Session, email: str):
db_user = db.query(models.User).filter(models.User.email == email).first()
if not db_user:
return None
return _fill_missing_user_fields(db_user)
def read_users(db: Session, skip: int = 0, limit: int = 100):
db_users = db.query(models.User).offset(skip).limit(limit).all()
full_users = []
for db_user in db_users:
full_users.append(_fill_missing_user_fields(db_user))
return full_users
def update_user(db: Session, user: schemas.UserUpdate, id: int):
db_user = db.query(models.User).filter(models.User.id == id).first()
current_user = _fill_missing_user_fields(db_user)
for key in ['gender', 'date_of_birth']:
value = getattr(user, key)
if value is not None:
setattr(db_user.patient, key, value)
for key in ['email', 'first_name', 'last_name']:
value = getattr(user, key)
if value is not None:
setattr(db_user, key, value)
if user.password is not None:
db_user.password = hash_password(user.password)
db.commit()
db.refresh(db_user)
return _fill_missing_user_fields(db_user)
def delete_user(db: Session, id: int):
db_user = db.query(models.User).filter(models.User.id == id).first()
user_copy = _fill_missing_user_fields(db_user)
db.delete(db_user)
db.commit()
user_copy.updated = datetime.now(user_copy.updated.tzinfo)
return user_copy

13
backend/database.py Normal file
View File

@ -0,0 +1,13 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.ext.declarative import declarative_base
from .config import get_settings
engine = create_engine(
get_settings().pg_dsn, # Get connection string from global settings
echo=get_settings().debug_mode # Get debugmode status from global settings
)
SessionLocal = sessionmaker(engine)
Base = declarative_base()

View File

@ -1,8 +1,66 @@
from fastapi import FastAPI, HTTPException
import logging
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
from . import crud, models, schemas
from.database import engine, SessionLocal
log = logging.getLogger()
models.Base.metadata.create_all(bind=engine)
app = FastAPI()
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.get("/hello/")
async def hello() -> str:
def hello():
return "Hello World!"
@app.post("/users/", response_model=schemas.User)
def create_user(user: schemas.UserCreate, db: Session = Depends(get_db)):
existing_user = crud.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 crud.create_user(db=db, user=user)
@app.get("/users/{id}", response_model=schemas.User)
def read_user(id: int, db: Session = Depends(get_db)):
user = crud.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[schemas.User])
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
users = crud.read_users(db=db, skip=skip, limit=limit)
return users
@app.patch("/users/{id}", response_model=schemas.User)
def update_user(id: int, user: schemas.UserUpdate, db: Session = Depends(get_db)):
current_user = crud.read_user(db=db, id=id)
if not current_user:
raise HTTPException(status_code=404, detail=f"No user with id '{id}' found.")
return crud.update_user(db=db, user=user, id=id)
@app.delete("/users/{id}", response_model=schemas.User)
def delete_user(id: int, db: Session = Depends(get_db)):
user = crud.read_user(db=db, id=id)
if not user:
raise HTTPException(status_code=404, detail=f"No user with id '{id}' found.")
return crud.delete_user(db=db, id=id)

45
backend/models.py Normal file
View File

@ -0,0 +1,45 @@
import enum
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Date, Enum, CheckConstraint
from sqlalchemy.sql.functions import now
from sqlalchemy.orm import relationship
from .database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
email = Column(String, unique=True, nullable=False)
password = Column(String, nullable=False)
created = Column(DateTime(timezone=True), nullable=False, server_default=now())
updated = Column(DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
first_name = Column(String, nullable=False)
last_name = Column(String, nullable=False)
administrator = relationship("Administrator", back_populates="user", uselist=False, cascade="all, delete")
patient = relationship("Patient", back_populates="user", uselist=False, cascade="all, delete")
#patient = Column(Integer, ForeignKey('patients.id'), nullable=True)
#CheckConstraint("(administrator=NULL AND patient!=NULL) OR (administrator!=NULL AND patient=NULL)")
class Administrator(Base):
__tablename__ = "administrators"
user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), primary_key=True,)
user = relationship("User", back_populates="administrator", uselist=False, cascade="all, delete")
class Gender(enum.Enum):
male = 'm'
female = 'f'
class Patient(Base):
__tablename__ = "patients"
user_id = Column(Integer, ForeignKey('users.id', ondelete="CASCADE"), primary_key=True)
user = relationship("User", back_populates="patient", uselist=False, cascade="all, delete")
date_of_birth = Column(Date, nullable=False)
gender = Column(Enum(Gender), nullable=False)

107
backend/schemas.py Normal file
View File

@ -0,0 +1,107 @@
from datetime import datetime, date
from abc import ABC
from typing import Optional
from pydantic import BaseModel, validator
from .models import Gender
class AbstractUserInfoValidation(BaseModel, ABC):
@validator('email', check_fields=False)
def assert_email_is_valid(cls, email):
if email is not None:
if not len(email):
raise ValueError("Email must not be empty.")
# TODO implement more robust check
return email
@validator('first_name', check_fields=False)
def assert_first_name_is_valid(cls, first_name):
if first_name is not None:
if not len(first_name):
raise ValueError("First Name must not be empty.")
return first_name
@validator('last_name', check_fields=False)
def assert_last_name_is_valid(cls, last_name):
if last_name is not None:
if not len(last_name):
raise ValueError("Last Name must not be empty.")
return last_name
@validator('date_of_birth', check_fields=False)
def assert_dob_is_valid(cls, dob):
if dob is not None:
if dob >= date.today():
raise ValueError("Date of birth cannot be in the future.")
return dob
class AbstractUser(AbstractUserInfoValidation, ABC):
email: str
first_name: str
last_name: str
gender: Optional[Gender]
date_of_birth: Optional[date]
is_patient: Optional[bool]
is_admin: Optional[bool]
@validator('is_admin')
def assert_tegridy(cls, is_admin, values):
if values['is_patient']:
if is_admin:
raise ValueError('User cannot be both patient and admin.')
for key in ['gender', 'date_of_birth']:
if key not in values or values[key] is None:
raise ValueError(f"Must specify key '{key}' for patients.")
if not values['is_patient'] and not is_admin:
raise ValueError(f'User must either be patient or admin.')
return is_admin
class UserCreate(AbstractUser):
password: str
password_confirmation: str
@validator('password_confirmation')
def assert_passwords_match(cls, password_confirmation, values):
if not password_confirmation == values['password']:
raise ValueError("Passwords do not match.")
if len(password_confirmation) < 1:
# TODO use more robust password rules
raise ValueError("Password must not be empty.")
return password_confirmation
class UserUpdate(AbstractUserInfoValidation):
email: Optional[str]
first_name: Optional[str]
last_name: Optional[str]
gender: Optional[Gender]
date_of_birth: Optional[date]
password: Optional[str] = None
password_confirmation: Optional[str] = None
@validator('password_confirmation')
def assert_passwords_match_or_are_both_none(cls, password_confirmation, values):
password = values.get('password')
if None not in [password, password_confirmation]:
if not password == password_confirmation:
raise ValueError("Passwords do not match.")
if len(password_confirmation) < 1:
# TODO use more robust password rules
raise ValueError("Password must not be empty.")
return password_confirmation
class User(AbstractUser):
id: int
created: datetime
updated: datetime
class Config:
orm_mode = True

View File

@ -6,6 +6,8 @@ services:
frontend:
container_name: frontend
restart: unless-stopped
depends_on:
- parcel
build:
context: .
dockerfile: ./development.frontend.Dockerfile
@ -19,7 +21,7 @@ services:
volumes:
- ./public/:/app/public/:ro
- ./robots/:/app/robots/:ro
- ./Caddyfile:/app/Caddyfile:ro
- ./development.Caddyfile:/app/Caddyfile:ro
parcel:
container_name: parcel
restart: unless-stopped
@ -39,6 +41,9 @@ services:
backend:
container_name: backend
restart: unless-stopped
depends_on:
- frontend
- db
build:
context: .
dockerfile: ./development.backend.Dockerfile
@ -50,5 +55,36 @@ services:
volumes:
- ./backend/:/app/backend/:ro
- ./requirements.txt:/app/requirements.txt:ro
environment:
APP_NAME: "MEDWingS"
ADMIN_EMAIL: "admin@example.com"
DEBUG_MODE: "true"
POSTGRES_HOST: "db"
POSTGRES_PORT: "5432"
POSTGRES_DB: "medwings"
POSTGRES_USER: "medwings"
POSTGRES_PASSWORD: "medwings"
db:
image: postgres
container_name: db
restart: unless-stopped
expose:
- "5432"
volumes:
- ./.postgres:/var/lib/postgresql/data
environment:
POSTGRES_DB: "medwings"
POSTGRES_USER: "medwings"
POSTGRES_PASSWORD: "medwings"
pgweb:
image: sosedoff/pgweb
container_name: pgweb
restart: unless-stopped
depends_on:
- db
ports:
- "8001:8081"
environment:
DATABSE_URL: "postgres://medwings:medwings@db:5432/medwings?sslmode=disable"
...

View File

@ -12,6 +12,9 @@ services:
args:
CUSTOM_UID: 1000
CUSTOM_GID: 1000
networks:
- proxy
- medwings
environment:
TZ: Europe/Berlin
ports:
@ -25,7 +28,38 @@ services:
args:
CUSTOM_UID: 1000
CUSTOM_GID: 1000
networks:
- medwings
expose:
- "3001"
environment:
APP_NAME: "MEDWingS"
ADMIN_EMAIL: "admin@example.com"
DEBUG_MODE: "true"
POSTGRES_HOST: "db"
POSTGRES_PORT: "5432"
POSTGRES_DB: "medwings"
POSTGRES_USER: "medwings"
POSTGRES_PASSWORD: "medwings"
db:
image: postgres
container_name: db
restart: unless-stopped
networks:
- medwings
expose:
- "5432"
volumes:
- /srv/medwings/db:/var/lib/postgresql/data
environment:
POSTGRES_DB: "medwings"
POSTGRES_USER: "medwings"
POSTGRES_PASSWORD: "medwings"
networks:
proxy:
external: true
medwings:
external: false
...

View File

@ -1,13 +1,17 @@
anyio==3.6.2
asyncpg==0.27.0
click==8.1.3
fastapi==0.88.0
greenlet==2.0.2
h11==0.14.0
httptools==0.5.0
idna==3.4
psycopg2-binary==2.9.6
pydantic==1.10.4
python-dotenv==0.21.0
PyYAML==6.0
sniffio==1.3.0
SQLAlchemy==2.0.13
starlette==0.22.0
typing_extensions==4.4.0
uvicorn==0.20.0