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