diff --git a/.gitignore b/.gitignore index cdd8d24..60b5d08 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,10 @@ /.venv/ /node_modules/ +# Local development database +/.postgres/ +/.omnidb/ + # Cache files and directories *.pyc __pycache__/ diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..b459467 --- /dev/null +++ b/backend/config.py @@ -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() diff --git a/backend/crud.py b/backend/crud.py new file mode 100644 index 0000000..6f5e889 --- /dev/null +++ b/backend/crud.py @@ -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 diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..419a3d8 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py index ceeb113..b3c2674 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..2029665 --- /dev/null +++ b/backend/models.py @@ -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) diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..2cf7051 --- /dev/null +++ b/backend/schemas.py @@ -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 diff --git a/development.docker-compose.yml b/development.docker-compose.yml index 5757627..2d393ef 100644 --- a/development.docker-compose.yml +++ b/development.docker-compose.yml @@ -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" ... diff --git a/production.docker-compose.yml b/production.docker-compose.yml index ac8620a..03a1c8c 100644 --- a/production.docker-compose.yml +++ b/production.docker-compose.yml @@ -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 ... diff --git a/requirements.txt b/requirements.txt index 5075bec..16ce17a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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