feat(backend): add database and api
This commit is contained in:
parent
7c04c2ef87
commit
7f2f5eebbe
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,6 +5,10 @@
|
||||
/.venv/
|
||||
/node_modules/
|
||||
|
||||
# Local development database
|
||||
/.postgres/
|
||||
/.omnidb/
|
||||
|
||||
# Cache files and directories
|
||||
*.pyc
|
||||
__pycache__/
|
||||
|
34
backend/config.py
Normal file
34
backend/config.py
Normal 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
114
backend/crud.py
Normal 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
13
backend/database.py
Normal 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()
|
@ -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
45
backend/models.py
Normal 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
107
backend/schemas.py
Normal 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
|
@ -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"
|
||||
|
||||
...
|
||||
|
@ -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
|
||||
|
||||
...
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user