feat(backend): add crud, routes, models and migrations
This commit is contained in:
parent
9e150a4efe
commit
9e33e9236f
27 changed files with 988 additions and 0 deletions
6
backend/.gitignore
vendored
Normal file
6
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
# Local environments
|
||||
.venv/
|
||||
|
||||
# Cache files and directories
|
||||
**/*.pyc
|
||||
**/__pycache__/
|
13
backend/README.md
Normal file
13
backend/README.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# TODO Backend
|
||||
|
||||
Built using fastapi.
|
||||
|
||||
## Database Migrations
|
||||
|
||||
You must run database migrations after first deploying the database, by running
|
||||
|
||||
```bash
|
||||
sudo docker exec -w /app/todo/database -it todo-backend alembic upgrade head
|
||||
```
|
||||
|
||||
once you have launched the `todo-backend`-container.
|
16
backend/requirements.txt
Normal file
16
backend/requirements.txt
Normal file
|
@ -0,0 +1,16 @@
|
|||
alembic==1.10.4
|
||||
anyio==3.6.2
|
||||
click==8.1.3
|
||||
fastapi==0.95.1
|
||||
greenlet==2.0.2
|
||||
h11==0.14.0
|
||||
idna==3.4
|
||||
Mako==1.2.4
|
||||
MarkupSafe==2.1.2
|
||||
psycopg2-binary==2.9.6
|
||||
pydantic==1.10.7
|
||||
sniffio==1.3.0
|
||||
SQLAlchemy==2.0.13
|
||||
starlette==0.26.1
|
||||
typing_extensions==4.5.0
|
||||
uvicorn==0.22.0
|
0
backend/todo/__init__.py
Normal file
0
backend/todo/__init__.py
Normal file
45
backend/todo/config.py
Normal file
45
backend/todo/config.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
"""This module provides global application settings.
|
||||
|
||||
All settings are read from environment variables, but defaults are provided below
|
||||
if the respective envvar is unset.
|
||||
"""
|
||||
|
||||
import os
|
||||
from urllib.parse import quote_plus as url_encode
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic import BaseSettings
|
||||
|
||||
|
||||
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", "TodoApp")
|
||||
app_version: str = os.getenv("APP_VERSION", "Unspecified")
|
||||
|
||||
contact_name: str = os.getenv("CONTACT_NAME", "TodoApp 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
|
||||
debug_mode: bool = False
|
||||
if os.getenv("DEBUG_MODE", "false").lower() == "true":
|
||||
debug_mode = True
|
||||
|
||||
pg_hostname = url_encode(os.getenv("POSTGRES_HOST", "todo-db"))
|
||||
pg_port = os.getenv("POSTGRES_PORT", "5432")
|
||||
pg_dbname = os.getenv("POSTGRES_DB", "todo")
|
||||
pg_user = url_encode(os.getenv("POSTGRES_USER", "todo"))
|
||||
pg_password = url_encode(os.getenv("POSTGRES_PASSWORD", "todo"))
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""Creates the settings once and returns a cached version on subsequent requests."""
|
||||
|
||||
return Settings()
|
0
backend/todo/crud/__init__.py
Normal file
0
backend/todo/crud/__init__.py
Normal file
92
backend/todo/crud/todos.py
Normal file
92
backend/todo/crud/todos.py
Normal file
|
@ -0,0 +1,92 @@
|
|||
"""This module handles CRUD operations for todo-items in the database, based on pydanctic schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.models import todos as todomodel
|
||||
from todo.models import users as usermodel
|
||||
from todo.schemas import todos as todoschema
|
||||
from todo.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
|
||||
|
||||
def create_todo(db: Session, todo: todoschema.TodoItemCreate, user_id: int) -> todoschema.TodoItem:
|
||||
"""Creates the specified todo-item for the user with the specified id in the database."""
|
||||
|
||||
db_user = db.query(usermodel.User).filter(usermodel.User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise NotFoundException(f"User with id '{user_id}' not found.")
|
||||
|
||||
db_todo = todomodel.TodoItem(
|
||||
user_id=user_id,
|
||||
title=todo.title,
|
||||
description=todo.description
|
||||
)
|
||||
|
||||
db.add(db_todo)
|
||||
db.commit()
|
||||
db.refresh(db_todo)
|
||||
return todoschema.TodoItem.from_orm(db_todo)
|
||||
|
||||
|
||||
def read_todo(db: Session, todo_id: int) -> todoschema.TodoItem:
|
||||
"""Queries the db for a todo-item with the specified id and returns it."""
|
||||
|
||||
db_todo = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.id == todo_id).first()
|
||||
if not db_todo:
|
||||
raise NotFoundException(f"Todo item with id '{todo_id}' not found.")
|
||||
|
||||
return todoschema.TodoItem.from_orm(db_todo)
|
||||
|
||||
|
||||
def read_todos_for_user(db: Session, user_id: int, skip: int = 0, limit: int = 100) -> list[todoschema.TodoItem]:
|
||||
"""Returns a range of todo-items of the user with the specified user_id from the database."""
|
||||
|
||||
for parameter in [skip, limit]:
|
||||
if not isinstance(parameter, int):
|
||||
raise InvalidFilterParameterException(f"Parameter '{parameter}' must be an integer.")
|
||||
if parameter < 0:
|
||||
raise InvalidFilterParameterException(f"Parameter '{parameter}' cannot be smaller than zero.")
|
||||
|
||||
db_user = db.query(usermodel.User).filter(usermodel.User.id == user_id).first()
|
||||
if not db_user:
|
||||
raise NotFoundException(f"User with id '{user_id}' not found.")
|
||||
|
||||
db_todos = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.user_id == user_id).offset(skip).limit(limit).all()
|
||||
return [todoschema.TodoItem.from_orm(db_todo) for db_todo in db_todos]
|
||||
|
||||
|
||||
def update_todo(db: Session, todo: todoschema.TodoItemUpdate, todo_id: int) -> todoschema.TodoItem:
|
||||
"""Updates the todo-item with the provided id with all non-None fields from the input todo-item."""
|
||||
|
||||
db_todo = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.id == todo_id).first()
|
||||
if not db_todo:
|
||||
raise NotFoundException(f"Todo item with id '{todo_id}' not found.")
|
||||
db_todo_orm_obj = todoschema.TodoItem.from_orm(db_todo)
|
||||
|
||||
for key in ['title', 'description', 'done']:
|
||||
value = getattr(todo, key)
|
||||
if value is not None:
|
||||
setattr(db_todo, key, value)
|
||||
# If we just newly finished a TODO, record the time it was finished
|
||||
if todo.done and not db_todo_orm_obj.done:
|
||||
setattr(db_todo, "finished", datetime.now(db_todo_orm_obj.updated.tzinfo))
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_todo)
|
||||
return todoschema.TodoItem.from_orm(db_todo)
|
||||
|
||||
|
||||
def delete_todo(db: Session, todo_id: int) -> todoschema.TodoItem:
|
||||
"""Deletes the todo-item with the provided id from the db."""
|
||||
|
||||
db_todo = db.query(todomodel.TodoItem).filter(todomodel.TodoItem.id == todo_id).first()
|
||||
if not db_todo:
|
||||
raise NotFoundException(f"Todo item with id '{todo_id}' not found.")
|
||||
todo_copy = todoschema.TodoItem.from_orm(db_todo)
|
||||
|
||||
db.delete(db_todo)
|
||||
db.commit()
|
||||
|
||||
todo_copy.updated = datetime.now(todo_copy.updated.tzinfo)
|
||||
return todo_copy
|
104
backend/todo/crud/users.py
Normal file
104
backend/todo/crud/users.py
Normal file
|
@ -0,0 +1,104 @@
|
|||
"""This module handles CRUD operations for users in the database, based on pydanctic schemas."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.models import users as usermodel
|
||||
from todo.schemas import users as userschema
|
||||
from todo.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""This is a placeholder for a secure password hashing algorithm.
|
||||
|
||||
It will convert a plaintext password into a secure, salted hash, for storage
|
||||
in the database.
|
||||
"""
|
||||
|
||||
# TODO actually hash the password!
|
||||
return password
|
||||
|
||||
|
||||
def create_user(db: Session, user: userschema.UserCreate) -> userschema.User:
|
||||
"""Creates the specified user in the database."""
|
||||
|
||||
db_user = usermodel.User(
|
||||
email=user.email,
|
||||
first_name=user.first_name,
|
||||
last_name=user.last_name,
|
||||
password=hash_password(user.password),
|
||||
)
|
||||
|
||||
db.add(db_user)
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return userschema.User.from_orm(db_user)
|
||||
|
||||
|
||||
def read_user(db: Session, id: int) -> userschema.User:
|
||||
"""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:
|
||||
raise NotFoundException(f"User with id '{id}' not found.")
|
||||
|
||||
return userschema.User.from_orm(db_user)
|
||||
|
||||
|
||||
def read_user_by_email(db: Session, email: str) -> userschema.User:
|
||||
"""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:
|
||||
raise NotFoundException(f"User with email '{email}' not found.")
|
||||
|
||||
return userschema.User.from_orm(db_user)
|
||||
|
||||
|
||||
def read_users(db: Session, skip: int = 0, limit: int = 100) -> list[userschema.User]:
|
||||
"""Returns an range of users from the database."""
|
||||
|
||||
for parameter in [skip, limit]:
|
||||
if not isinstance(parameter, int):
|
||||
raise InvalidFilterParameterException(f"Parameter '{parameter}' must be an integer.")
|
||||
if parameter < 0:
|
||||
raise InvalidFilterParameterException(f"Parameter '{parameter}' cannot be smaller than zero.")
|
||||
|
||||
db_users = db.query(usermodel.User).offset(skip).limit(limit).all()
|
||||
|
||||
return [userschema.User.from_orm(db_user) for db_user in db_users]
|
||||
|
||||
|
||||
def update_user(db: Session, user: userschema.UserUpdate, id: int) -> userschema.User:
|
||||
"""Updates the user with the provided id with all non-None fields from the input user."""
|
||||
|
||||
db_user = db.query(usermodel.User).filter(usermodel.User.id == id).first()
|
||||
if not db_user:
|
||||
raise NotFoundException(f"User with id '{id}' not found.")
|
||||
|
||||
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:
|
||||
setattr(db_user, "password", hash_password(user.password))
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_user)
|
||||
return userschema.User.from_orm(db_user)
|
||||
|
||||
|
||||
def delete_user(db: Session, id: int) -> userschema.User:
|
||||
"""Deletes the user with the provided id from the db."""
|
||||
|
||||
db_user = db.query(usermodel.User).filter(usermodel.User.id == id).first()
|
||||
if not db_user:
|
||||
raise NotFoundException(f"User with id '{id}' not found.")
|
||||
user_copy = userschema.User.from_orm(db_user)
|
||||
|
||||
db.delete(db_user)
|
||||
db.commit()
|
||||
|
||||
user_copy.updated = datetime.now(user_copy.updated.tzinfo)
|
||||
return user_copy
|
0
backend/todo/database/__init__.py
Normal file
0
backend/todo/database/__init__.py
Normal file
110
backend/todo/database/alembic.ini
Normal file
110
backend/todo/database/alembic.ini
Normal file
|
@ -0,0 +1,110 @@
|
|||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
script_location = migrations
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = ../..
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python-dateutil library that can be
|
||||
# installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to dateutil.tz.gettz()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the
|
||||
# "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to migrations/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
27
backend/todo/database/engine.py
Normal file
27
backend/todo/database/engine.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
"""This module configures and provides the sqlalchemy session factory and base model."""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
from todo.config import get_settings
|
||||
|
||||
|
||||
s = get_settings()
|
||||
|
||||
# The SQL driver is specified by the DSN-prefix below.
|
||||
_pg_dsn = f"postgresql+psycopg2://{s.pg_user}:{s.pg_password}@{s.pg_hostname}:{s.pg_port}/{s.pg_dbname}"
|
||||
engine = create_engine(_pg_dsn, echo=s.debug_mode)
|
||||
|
||||
# SQLalchemy session factory
|
||||
SessionLocal = sessionmaker(engine)
|
||||
# SQLalchemy base model
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
80
backend/todo/database/migrations/env.py
Normal file
80
backend/todo/database/migrations/env.py
Normal file
|
@ -0,0 +1,80 @@
|
|||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config
|
||||
from sqlalchemy import pool
|
||||
from alembic import context
|
||||
|
||||
from todo.database.engine import _pg_dsn as pg_connection_str
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
config.set_main_option('sqlalchemy.url', pg_connection_str)
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
target_metadata = None
|
||||
|
||||
# other values from the config, defined by the needs of env.py,
|
||||
# can be acquired:
|
||||
# my_important_option = config.get_main_option("my_important_option")
|
||||
# ... etc.
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection, target_metadata=target_metadata
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
24
backend/todo/database/migrations/script.py.mako
Normal file
24
backend/todo/database/migrations/script.py.mako
Normal file
|
@ -0,0 +1,24 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
|
@ -0,0 +1,34 @@
|
|||
"""create user tables
|
||||
|
||||
Revision ID: 335e07a98bc8
|
||||
Revises:
|
||||
Create Date: 2023-05-12 19:59:22.188464
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql.functions import now
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '335e07a98bc8'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
'users',
|
||||
Column('id', Integer, primary_key=True, autoincrement=True, index=True),
|
||||
Column('email', String, unique=True, nullable=False),
|
||||
Column('password', String, nullable=False),
|
||||
Column('created', DateTime(timezone=True), nullable=False, server_default=now()),
|
||||
Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()),
|
||||
Column('first_name', String, nullable=False),
|
||||
Column('last_name', String, nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('users')
|
|
@ -0,0 +1,36 @@
|
|||
"""create todo tables
|
||||
|
||||
Revision ID: 7e5a8cabd3a4
|
||||
Revises: 335e07a98bc8
|
||||
Create Date: 2023-05-12 21:59:26.867894
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Boolean
|
||||
from sqlalchemy.sql.functions import now
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '7e5a8cabd3a4'
|
||||
down_revision = '335e07a98bc8'
|
||||
branch_labels = None
|
||||
depends_on = down_revision
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
op.create_table(
|
||||
'todo_items',
|
||||
Column('id', Integer, primary_key=True, autoincrement=True, index=True),
|
||||
Column('title', String, nullable=False),
|
||||
Column('description', String, nullable=False),
|
||||
Column('done', Boolean, nullable=False, default=False, index=True),
|
||||
Column('created', DateTime(timezone=True), nullable=False, server_default=now()),
|
||||
Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now()),
|
||||
Column('finished', DateTime(timezone=True), nullable=True, default=None),
|
||||
Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table('todo_items')
|
19
backend/todo/exceptions.py
Normal file
19
backend/todo/exceptions.py
Normal file
|
@ -0,0 +1,19 @@
|
|||
"""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
|
||||
|
||||
|
||||
class InvalidFilterParameterException(Exception):
|
||||
"""Raised when a query filter parameter is invalid."""
|
||||
|
||||
pass
|
39
backend/todo/main.py
Normal file
39
backend/todo/main.py
Normal file
|
@ -0,0 +1,39 @@
|
|||
"""Main entry point for the MEDWingS backend.
|
||||
|
||||
This module defines the API routes provided by the backend.
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from todo import config
|
||||
from todo.routes.users import router as users_router
|
||||
from todo.routes.users import tag_metadata as users_tags
|
||||
from todo.routes.todos import router as todos_router
|
||||
from todo.routes.todos import tag_metadata as todos_tags
|
||||
|
||||
|
||||
s = config.get_settings()
|
||||
app = FastAPI(
|
||||
title=f"{s.app_name} API",
|
||||
description=f"This is the backend server API for {s.app_name}, a simple TODO-list application.",
|
||||
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,
|
||||
todos_tags,
|
||||
],
|
||||
)
|
||||
|
||||
app.include_router(users_router)
|
||||
app.include_router(todos_router)
|
||||
|
||||
|
||||
@app.get("/hello/")
|
||||
def hello():
|
||||
"""Placeholder for a proper healthcheck endpoint."""
|
||||
|
||||
return "Hello World!"
|
0
backend/todo/models/__init__.py
Normal file
0
backend/todo/models/__init__.py
Normal file
28
backend/todo/models/todos.py
Normal file
28
backend/todo/models/todos.py
Normal file
|
@ -0,0 +1,28 @@
|
|||
"""This module defines the SQL data model for todo items."""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Integer, String, DateTime, Date, Enum, CheckConstraint, Boolean
|
||||
from sqlalchemy.sql.functions import now
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from todo.database.engine import Base
|
||||
from todo.models.users import User
|
||||
|
||||
|
||||
class TodoItem(Base):
|
||||
"""Model for the todo_items table."""
|
||||
|
||||
__tablename__ = "todo_items"
|
||||
|
||||
id = Column('id', Integer, primary_key=True, autoincrement=True, index=True)
|
||||
title = Column('title', String, nullable=False)
|
||||
description = Column('description', String, nullable=False)
|
||||
done = Column('done', Boolean, nullable=False, default=False, index=True)
|
||||
|
||||
created = Column('created', DateTime(timezone=True), nullable=False, server_default=now())
|
||||
updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
|
||||
finished = Column('finished', DateTime(timezone=True), nullable=True, default=None)
|
||||
|
||||
user_id = Column('user_id', Integer, ForeignKey('users.id', ondelete="CASCADE"), nullable=False)
|
||||
user = relationship(User, back_populates="todo_items", uselist=False)
|
25
backend/todo/models/users.py
Normal file
25
backend/todo/models/users.py
Normal file
|
@ -0,0 +1,25 @@
|
|||
"""This module defines the SQL data model for users."""
|
||||
|
||||
import enum
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
from sqlalchemy.sql.functions import now
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from todo.database.engine import Base
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Model for the users table."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column('id', Integer, primary_key=True, autoincrement=True, index=True)
|
||||
email = Column('email', String, unique=True, nullable=False)
|
||||
password = Column('password', String, nullable=False)
|
||||
created = Column('created', DateTime(timezone=True), nullable=False, server_default=now())
|
||||
updated = Column('updated', DateTime(timezone=True), nullable=False, server_default=now(), onupdate=now())
|
||||
first_name = Column('first_name', String, nullable=False)
|
||||
last_name = Column('last_name', String, nullable=False)
|
||||
|
||||
todo_items = relationship("TodoItem", back_populates="user", uselist=True, cascade="all, delete")
|
0
backend/todo/routes/__init__.py
Normal file
0
backend/todo/routes/__init__.py
Normal file
62
backend/todo/routes/todos.py
Normal file
62
backend/todo/routes/todos.py
Normal file
|
@ -0,0 +1,62 @@
|
|||
"""This module contains endpoints for operations related to users."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.schemas import todos as todoschema
|
||||
from todo.crud import todos as todocrud
|
||||
from todo.exceptions import NotFoundException, InvalidFilterParameterException
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/todo",
|
||||
tags=["todo-items"]
|
||||
)
|
||||
|
||||
tag_metadata = {
|
||||
"name": "todo-items",
|
||||
"description": "Operations related to todo items."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/user/{user_id}", response_model=todoschema.TodoItem)
|
||||
def create_todo(todo: todoschema.TodoItemCreate, user_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return todocrud.create_todo(db=db, todo=todo, user_id=user_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.get("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def read_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return todocrud.read_todo(db=db, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.get("/user/{user_id}", response_model=list[todoschema.TodoItem])
|
||||
def read_todos(user_id: int, skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return todocrud.read_todos_for_user(db=db, user_id=user_id, skip=skip, limit=limit)
|
||||
except InvalidFilterParameterException as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.patch("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def update_todo(todo_id: int, todo: todoschema.TodoItemUpdate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return todocrud.update_todo(db=db, todo=todo, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, str(e))
|
||||
|
||||
|
||||
@router.delete("/{todo_id}", response_model=todoschema.TodoItem)
|
||||
def delete_todo(todo_id: int, db: Session = Depends(get_db)):
|
||||
try:
|
||||
return todocrud.delete_todo(db=db, todo_id=todo_id)
|
||||
except NotFoundException as e:
|
||||
raise HTTPException(404, str(e))
|
59
backend/todo/routes/users.py
Normal file
59
backend/todo/routes/users.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
"""This module contains endpoints for operations related to users."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from todo.database.engine import get_db
|
||||
from todo.schemas import users as userschema
|
||||
from todo.crud import users as usercrud
|
||||
from todo.exceptions import NotFoundException
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/users",
|
||||
tags=["users"]
|
||||
)
|
||||
|
||||
tag_metadata = {
|
||||
"name": "users",
|
||||
"description": "Operations related to users."
|
||||
}
|
||||
|
||||
|
||||
@router.post("/", response_model=userschema.User)
|
||||
def create_user(user: userschema.UserCreate, db: Session = Depends(get_db)):
|
||||
try:
|
||||
# An exception is expected here, because we need to check if a user with this email is already registered
|
||||
usercrud.read_user_by_email(db, email=user.email)
|
||||
raise HTTPException(400, "A user with this email address is already registered.")
|
||||
except NotFoundException:
|
||||
return usercrud.create_user(db=db, user=user)
|
||||
|
||||
|
||||
@router.get("/{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("/", response_model=list[userschema.User])
|
||||
def read_users(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
return usercrud.read_users(db=db, skip=skip, limit=limit)
|
||||
|
||||
|
||||
@router.patch("/{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("/{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))
|
0
backend/todo/schemas/__init__.py
Normal file
0
backend/todo/schemas/__init__.py
Normal file
59
backend/todo/schemas/todos.py
Normal file
59
backend/todo/schemas/todos.py
Normal file
|
@ -0,0 +1,59 @@
|
|||
"""This module declareds the pydantic API representation for todo-items."""
|
||||
|
||||
from datetime import datetime
|
||||
from abc import ABC
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
|
||||
class AbstractTodoItemValidator(BaseModel, ABC):
|
||||
"""Base class for todo-item validators shared with child classes."""
|
||||
|
||||
@validator('title', check_fields=False)
|
||||
def assert_title_is_valid(cls, title):
|
||||
if title is not None:
|
||||
if not len(title):
|
||||
raise ValueError("Title must not be empty.")
|
||||
return title
|
||||
|
||||
@validator('description', check_fields=False)
|
||||
def assert_last_name_is_valid(cls, description):
|
||||
if description is not None:
|
||||
if not len(description):
|
||||
raise ValueError("Description must not be empty.")
|
||||
return description
|
||||
|
||||
|
||||
class AbstractTodoItem(AbstractTodoItemValidator, ABC):
|
||||
"""Base class for todo-item attributes shared with child classes."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
|
||||
|
||||
class TodoItemCreate(AbstractTodoItem):
|
||||
"""Schema for todo-item creation."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TodoItemUpdate(AbstractTodoItemValidator):
|
||||
"""Schema for todo-item updates."""
|
||||
|
||||
title: Optional[str]
|
||||
description: Optional[str]
|
||||
done: Optional[bool]
|
||||
|
||||
|
||||
class TodoItem(AbstractTodoItem):
|
||||
"""Schema for user info displaying."""
|
||||
|
||||
id: int
|
||||
done: bool
|
||||
created: datetime
|
||||
updated: datetime
|
||||
finished: datetime | None
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
110
backend/todo/schemas/users.py
Normal file
110
backend/todo/schemas/users.py
Normal file
|
@ -0,0 +1,110 @@
|
|||
"""This module declareds the pydantic ORM representation for users."""
|
||||
|
||||
from datetime import datetime
|
||||
from abc import ABC
|
||||
from typing import Optional
|
||||
from re import compile
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
|
||||
def is_valid_email_format(input_str: str) -> bool:
|
||||
"""Checks whether the input string is a valid email address format.
|
||||
|
||||
Uses a regular expression to perform the check.
|
||||
"""
|
||||
|
||||
if not isinstance(input_str, str):
|
||||
raise TypeError(f"Expected a string but got {type(input_str)}.")
|
||||
|
||||
regex = compile(r'^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$')
|
||||
if regex.fullmatch(input_str):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
class AbstractUserValidator(BaseModel, ABC):
|
||||
"""Base class for user validators shared with child classes."""
|
||||
|
||||
@validator('email', check_fields=False)
|
||||
def assert_email_is_valid(cls, email):
|
||||
if email is not None:
|
||||
if not is_valid_email_format(email):
|
||||
raise ValueError("Invalid email address format.")
|
||||
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
|
||||
|
||||
|
||||
class AbstractUser(AbstractUserValidator, ABC):
|
||||
"""Base class for user attributes shared with child classes."""
|
||||
|
||||
email: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
|
||||
|
||||
class UserCreate(AbstractUser):
|
||||
"""Schema for user creation."""
|
||||
|
||||
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(AbstractUserValidator):
|
||||
"""Schema for user info updates.
|
||||
|
||||
All fields here are optional, but passwords must match if at least one was
|
||||
provided.
|
||||
"""
|
||||
|
||||
email: Optional[str]
|
||||
first_name: Optional[str]
|
||||
last_name: Optional[str]
|
||||
|
||||
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):
|
||||
"""Schema for user info displaying."""
|
||||
|
||||
id: int
|
||||
created: datetime
|
||||
updated: datetime
|
||||
|
||||
class Config:
|
||||
orm_mode = True
|
Loading…
Add table
Reference in a new issue