diff --git a/frontend/README.md b/README.md similarity index 100% rename from frontend/README.md rename to README.md diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..142d211 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,6 @@ +# Local environments +.venv/ + +# Cache files and directories +**/*.pyc +**/__pycache__/ diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..3581ec8 --- /dev/null +++ b/backend/README.md @@ -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. diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..25e846a --- /dev/null +++ b/backend/requirements.txt @@ -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 diff --git a/backend/todo/__init__.py b/backend/todo/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/config.py b/backend/todo/config.py new file mode 100644 index 0000000..aa3abcf --- /dev/null +++ b/backend/todo/config.py @@ -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() diff --git a/backend/todo/crud/__init__.py b/backend/todo/crud/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/crud/todos.py b/backend/todo/crud/todos.py new file mode 100644 index 0000000..85e2fe5 --- /dev/null +++ b/backend/todo/crud/todos.py @@ -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 diff --git a/backend/todo/crud/users.py b/backend/todo/crud/users.py new file mode 100644 index 0000000..77aac3f --- /dev/null +++ b/backend/todo/crud/users.py @@ -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 diff --git a/backend/todo/database/__init__.py b/backend/todo/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/database/alembic.ini b/backend/todo/database/alembic.ini new file mode 100644 index 0000000..778a46d --- /dev/null +++ b/backend/todo/database/alembic.ini @@ -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 diff --git a/backend/todo/database/engine.py b/backend/todo/database/engine.py new file mode 100644 index 0000000..048ba48 --- /dev/null +++ b/backend/todo/database/engine.py @@ -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() diff --git a/backend/todo/database/migrations/env.py b/backend/todo/database/migrations/env.py new file mode 100644 index 0000000..0b39074 --- /dev/null +++ b/backend/todo/database/migrations/env.py @@ -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() diff --git a/backend/todo/database/migrations/script.py.mako b/backend/todo/database/migrations/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/backend/todo/database/migrations/script.py.mako @@ -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"} diff --git a/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py b/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py new file mode 100644 index 0000000..8e942e2 --- /dev/null +++ b/backend/todo/database/migrations/versions/335e07a98bc8_create_user_tables.py @@ -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') diff --git a/backend/todo/database/migrations/versions/7e5a8cabd3a4_create_todo_tables.py b/backend/todo/database/migrations/versions/7e5a8cabd3a4_create_todo_tables.py new file mode 100644 index 0000000..a72f9ea --- /dev/null +++ b/backend/todo/database/migrations/versions/7e5a8cabd3a4_create_todo_tables.py @@ -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') diff --git a/backend/todo/exceptions.py b/backend/todo/exceptions.py new file mode 100644 index 0000000..0d10b2c --- /dev/null +++ b/backend/todo/exceptions.py @@ -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 diff --git a/backend/todo/main.py b/backend/todo/main.py new file mode 100644 index 0000000..df9b284 --- /dev/null +++ b/backend/todo/main.py @@ -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!" diff --git a/backend/todo/models/__init__.py b/backend/todo/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/models/todos.py b/backend/todo/models/todos.py new file mode 100644 index 0000000..6eedd0c --- /dev/null +++ b/backend/todo/models/todos.py @@ -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) diff --git a/backend/todo/models/users.py b/backend/todo/models/users.py new file mode 100644 index 0000000..00e4990 --- /dev/null +++ b/backend/todo/models/users.py @@ -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") diff --git a/backend/todo/routes/__init__.py b/backend/todo/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/routes/todos.py b/backend/todo/routes/todos.py new file mode 100644 index 0000000..179a4fb --- /dev/null +++ b/backend/todo/routes/todos.py @@ -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)) diff --git a/backend/todo/routes/users.py b/backend/todo/routes/users.py new file mode 100644 index 0000000..21cfae9 --- /dev/null +++ b/backend/todo/routes/users.py @@ -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)) diff --git a/backend/todo/schemas/__init__.py b/backend/todo/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/todo/schemas/todos.py b/backend/todo/schemas/todos.py new file mode 100644 index 0000000..550ff24 --- /dev/null +++ b/backend/todo/schemas/todos.py @@ -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 diff --git a/backend/todo/schemas/users.py b/backend/todo/schemas/users.py new file mode 100644 index 0000000..310246e --- /dev/null +++ b/backend/todo/schemas/users.py @@ -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