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/
|
/.venv/
|
||||||
/node_modules/
|
/node_modules/
|
||||||
|
|
||||||
|
# Local development database
|
||||||
|
/.postgres/
|
||||||
|
/.omnidb/
|
||||||
|
|
||||||
# Cache files and directories
|
# Cache files and directories
|
||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__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()
|
app = FastAPI()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
@app.get("/hello/")
|
@app.get("/hello/")
|
||||||
async def hello() -> str:
|
def hello():
|
||||||
return "Hello World!"
|
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:
|
frontend:
|
||||||
container_name: frontend
|
container_name: frontend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- parcel
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./development.frontend.Dockerfile
|
dockerfile: ./development.frontend.Dockerfile
|
||||||
@ -19,7 +21,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./public/:/app/public/:ro
|
- ./public/:/app/public/:ro
|
||||||
- ./robots/:/app/robots/:ro
|
- ./robots/:/app/robots/:ro
|
||||||
- ./Caddyfile:/app/Caddyfile:ro
|
- ./development.Caddyfile:/app/Caddyfile:ro
|
||||||
parcel:
|
parcel:
|
||||||
container_name: parcel
|
container_name: parcel
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@ -39,6 +41,9 @@ services:
|
|||||||
backend:
|
backend:
|
||||||
container_name: backend
|
container_name: backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- frontend
|
||||||
|
- db
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./development.backend.Dockerfile
|
dockerfile: ./development.backend.Dockerfile
|
||||||
@ -50,5 +55,36 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend/:/app/backend/:ro
|
- ./backend/:/app/backend/:ro
|
||||||
- ./requirements.txt:/app/requirements.txt: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:
|
args:
|
||||||
CUSTOM_UID: 1000
|
CUSTOM_UID: 1000
|
||||||
CUSTOM_GID: 1000
|
CUSTOM_GID: 1000
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
- medwings
|
||||||
environment:
|
environment:
|
||||||
TZ: Europe/Berlin
|
TZ: Europe/Berlin
|
||||||
ports:
|
ports:
|
||||||
@ -25,7 +28,38 @@ services:
|
|||||||
args:
|
args:
|
||||||
CUSTOM_UID: 1000
|
CUSTOM_UID: 1000
|
||||||
CUSTOM_GID: 1000
|
CUSTOM_GID: 1000
|
||||||
|
networks:
|
||||||
|
- medwings
|
||||||
expose:
|
expose:
|
||||||
- "3001"
|
- "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
|
anyio==3.6.2
|
||||||
|
asyncpg==0.27.0
|
||||||
click==8.1.3
|
click==8.1.3
|
||||||
fastapi==0.88.0
|
fastapi==0.88.0
|
||||||
|
greenlet==2.0.2
|
||||||
h11==0.14.0
|
h11==0.14.0
|
||||||
httptools==0.5.0
|
httptools==0.5.0
|
||||||
idna==3.4
|
idna==3.4
|
||||||
|
psycopg2-binary==2.9.6
|
||||||
pydantic==1.10.4
|
pydantic==1.10.4
|
||||||
python-dotenv==0.21.0
|
python-dotenv==0.21.0
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
sniffio==1.3.0
|
sniffio==1.3.0
|
||||||
|
SQLAlchemy==2.0.13
|
||||||
starlette==0.22.0
|
starlette==0.22.0
|
||||||
typing_extensions==4.4.0
|
typing_extensions==4.4.0
|
||||||
uvicorn==0.20.0
|
uvicorn==0.20.0
|
||||||
|
Loading…
x
Reference in New Issue
Block a user