From 6b5ade9bc36f0711a5f93076f912a9306efc5b51 Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Fri, 24 Nov 2023 17:55:10 -0300 Subject: [PATCH 1/6] Create Dockerfile for FastAPI app --- README.md | 16 ++++++++++++++++ app/core/.env.example | 6 ++++++ app/core/settings.py | 2 +- docker/Dockerfile | 26 ++++++++++++++++++++++++++ pyproject.toml | 4 ++-- 5 files changed, 51 insertions(+), 3 deletions(-) create mode 100644 app/core/.env.example create mode 100644 docker/Dockerfile diff --git a/README.md b/README.md index bbef185..ef5442c 100644 --- a/README.md +++ b/README.md @@ -26,3 +26,19 @@ $ poetry poe precommit # run pre-commit hooks More arguments can be added to the base commands, for example `poetry poe start --reload`. There are also [pre-commit hooks](https://pre-commit.com/) configured to run the [Ruff](https://github.com/astral-sh/ruff) linter and code formatter. To install them, run `pre-commit install`. + +## Run with Docker + +1. Build the image (`-t` to tag the image). +```bash +$ docker build -f docker/Dockerfile -t fastapi_quiz . +``` +2. Run the container (`-d` for detached mode to run the container in the background, `-p HOST:CONTAINER` to map the ports). +```bash +$ docker run -d -p 8000:8000 --name fastapi_cont fastapi_quiz +``` +3. The app can now be accessed from http://0.0.0.0:8000 (docs at http://0.0.0.0:8000/docs). +4. Access app logs (`-f` to follow the logs). +```bash +$ docker logs fastapi_cont -f +``` diff --git a/app/core/.env.example b/app/core/.env.example new file mode 100644 index 0000000..c296758 --- /dev/null +++ b/app/core/.env.example @@ -0,0 +1,6 @@ +PROJECT_NAME= +ENVIRONMENT= + +DB_URL= +USE_ALEMBIC= +ECHO_SQL= diff --git a/app/core/settings.py b/app/core/settings.py index 17cbc3b..fbded17 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -4,7 +4,7 @@ class Settings(BaseSettings): - APP_NAME: str = "Quiz App" + PROJECT_NAME: str = "Quiz App" ENVIRONMENT: str = "dev" DB_URL: str = "sqlite+aiosqlite:///./sqlite_dev.db" ECHO_SQL: bool = False diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..29d9c95 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,26 @@ +# start from the official Python 3.11 image +FROM python:3.11 + +# set current working directory to /code +WORKDIR /code + +# install Poetry +RUN pip install "poetry==1.6.1" + +# copy only the requirements files (to take advantage of Docker cache) +COPY ./pyproject.toml /code/pyproject.toml +COPY ./poetry.lock /code/poetry.lock + +# install dependencies +RUN poetry install +RUN poetry self add 'poethepoet[poetry_plugin]' + +# copy the app inside the /code directory +COPY ./app /code/app + +ENV PYTHONPATH /code + +EXPOSE 8000 + +# run command to start Uvicorn server +CMD ["poetry", "poe", "start"] diff --git a/pyproject.toml b/pyproject.toml index 6b85955..40523a0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,9 +35,9 @@ version = "0.1.0" description = "Backend for Quiz App" license = "MIT" authors = ["Matias B"] -readme = "README.md" repository = "https://github.com/mbrignone/quiz-app" -packages = [{include = "app"}] +# readme = "README.md" +# packages = [{include = "app"}] [tool.poetry.dependencies] python = "^3.11" From 7323d2ed7ef26eab4b5d91d39ea9c52b8faae1d0 Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Sat, 25 Nov 2023 11:57:38 -0300 Subject: [PATCH 2/6] Add Docker Compose file with PostgreSQL support --- .env.example | 13 +++++++++++ docker/Dockerfile => Dockerfile | 0 README.md | 34 +++++++++++++++++++++++----- app/core/.env.example | 6 ----- app/core/settings.py | 25 ++++++++++++++++++++- app/models/database.py | 2 +- app/models/quiz.py | 2 +- docker-compose.yml | 39 +++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 9 files changed, 107 insertions(+), 16 deletions(-) create mode 100644 .env.example rename docker/Dockerfile => Dockerfile (100%) delete mode 100644 app/core/.env.example create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7adcb46 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +PROJECT_NAME= +ENVIRONMENT= + +USE_SQLITE= +USE_ALEMBIC= +ECHO_SQL= + +DB_BASE= +POSTGRES_USER= +POSTGRES_PASSWORD= +POSTGRES_DB= +POSTGRES_SERVER= +POSTGRES_PORT= diff --git a/docker/Dockerfile b/Dockerfile similarity index 100% rename from docker/Dockerfile rename to Dockerfile diff --git a/README.md b/README.md index ef5442c..78bd7b2 100644 --- a/README.md +++ b/README.md @@ -27,18 +27,40 @@ More arguments can be added to the base commands, for example `poetry poe start There are also [pre-commit hooks](https://pre-commit.com/) configured to run the [Ruff](https://github.com/astral-sh/ruff) linter and code formatter. To install them, run `pre-commit install`. -## Run with Docker +## Run with Docker (SQLite) -1. Build the image (`-t` to tag the image). +1. Set `USE_SQLITE=true` in the `.env` file. +2. Build the image (`-t` to tag the image). ```bash -$ docker build -f docker/Dockerfile -t fastapi_quiz . +$ docker build -t fastapi_quiz . ``` -2. Run the container (`-d` for detached mode to run the container in the background, `-p HOST:CONTAINER` to map the ports). +3. Run the container (`-d` for detached mode to run the container in the background, `-p HOST:CONTAINER` to map the ports). ```bash $ docker run -d -p 8000:8000 --name fastapi_cont fastapi_quiz ``` -3. The app can now be accessed from http://0.0.0.0:8000 (docs at http://0.0.0.0:8000/docs). -4. Access app logs (`-f` to follow the logs). +4. The app can now be accessed from http://0.0.0.0:8000 (docs at http://0.0.0.0:8000/docs). +5. Access app logs (`-f` to follow the logs). ```bash $ docker logs fastapi_cont -f ``` +6. Stop and remove the containers. +```bash +$ docker stop fastapi_cont +$ docker rm fastapi_cont +``` + +## Run with Docker Compose (PostgreSQL) +1. Run docker compose (optional `--build` to rebuild the images). +```bash +$ docker compose up -d +``` +2. The app can now be accessed from http://0.0.0.0:8000 (docs at http://0.0.0.0:8000/docs). The pgAdmin platform can be accessed from http://0.0.0.0:5050, using `email=pgadmin4@pgadmin.org` and `password=admin` to login (they are defined in the `docker-compose.yml` file). +3. Access app logs (-f to follow the logs). +```bash +$ docker logs fastapi_app -f # FastAPI app logs +$ docker logs postgres_db -f # PostgreSQL logs +``` +4. Stop and remove the containers (`-v` to also delete the volume used for PostgreSQL data). +```bash +$ docker compose down -v +``` diff --git a/app/core/.env.example b/app/core/.env.example deleted file mode 100644 index c296758..0000000 --- a/app/core/.env.example +++ /dev/null @@ -1,6 +0,0 @@ -PROJECT_NAME= -ENVIRONMENT= - -DB_URL= -USE_ALEMBIC= -ECHO_SQL= diff --git a/app/core/settings.py b/app/core/settings.py index fbded17..826dffb 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -6,12 +6,35 @@ class Settings(BaseSettings): PROJECT_NAME: str = "Quiz App" ENVIRONMENT: str = "dev" - DB_URL: str = "sqlite+aiosqlite:///./sqlite_dev.db" + ECHO_SQL: bool = False USE_ALEMBIC: bool = False + USE_SQLITE: bool = False + + DB_BASE: str = "postgresql+asyncpg" + POSTGRES_USER: str = "postgres" + POSTGRES_PASSWORD: str = "postgres" + POSTGRES_DB: str = "postgres" + POSTGRES_SERVER: str = "postgres" + POSTGRES_PORT: int = 5432 model_config = SettingsConfigDict(env_file=".env") + def get_db_url(self) -> str: + """ + A function is needed (instead of directly creating the URL + as an additional attribute) to make sure that the values from the .env file + are used (instead of the default values). + """ + if self.USE_SQLITE: + return "sqlite+aiosqlite:///./sqlite_dev.db" + + return ( + f"{self.DB_BASE}://" + f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}" + f"@{self.POSTGRES_SERVER}:{self.POSTGRES_PORT}/{self.POSTGRES_DB}" + ) + @lru_cache def get_settings() -> Settings: diff --git a/app/models/database.py b/app/models/database.py index a298e5f..ba74f8a 100644 --- a/app/models/database.py +++ b/app/models/database.py @@ -75,7 +75,7 @@ async def delete_by_id(cls, db: AsyncSession, id: int) -> Self | None: settings = get_settings() async_engine = create_async_engine( - settings.DB_URL, pool_pre_ping=True, echo=settings.ECHO_SQL + settings.get_db_url(), pool_pre_ping=True, echo=settings.ECHO_SQL ) AsyncSessionLocal = async_sessionmaker(bind=async_engine, autoflush=False, future=True) diff --git a/app/models/quiz.py b/app/models/quiz.py index d6c4861..27e890e 100644 --- a/app/models/quiz.py +++ b/app/models/quiz.py @@ -17,7 +17,7 @@ class Quiz(Base): __tablename__ = "quizzes" title: Mapped[str] = mapped_column(String(128), nullable=False, index=True) - description: Mapped[str] = mapped_column(String(512), nullable=True, index=True) + description: Mapped[str] = mapped_column(String(512), nullable=True) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now() ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c36be57 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: "3.8" +services: + fastapi: + container_name: fastapi_app + build: + context: . + dockerfile: Dockerfile + ports: + - 8000:8000 + env_file: + - ./.env + # environment: + # - USE_ALEMBIC=true + depends_on: + - postgres + postgres: + container_name: postgres_db + image: postgres:15-alpine + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - ./.env + # no need to expose DB port, host machine doesn't access it + # ports: + # - 5432:5432 + pgadmin: + container_name: pgadmin + image: dpage/pgadmin4 + environment: + - PGADMIN_DEFAULT_EMAIL=pgadmin4@pgadmin.org + - PGADMIN_DEFAULT_PASSWORD=admin + ports: + - "5050:80" + depends_on: + - postgres + +# named volumes +volumes: + postgres_data: diff --git a/pyproject.toml b/pyproject.toml index 40523a0..2d8acef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,7 @@ asyncio_mode = "auto" [tool.pytest_env] ENVIRONMENT = "test" USE_ALEMBIC = false -DB_URL = "sqlite+aiosqlite:///./sqlite_test.db" +USE_SQLITE = true [tool.coverage.run] branch = true From 39a819cf5b700af5e079773ca9a264e50e5eff1a Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Sun, 26 Nov 2023 00:53:12 -0300 Subject: [PATCH 3/6] Configure Alembic and add first revision --- alembic.ini | 114 ++++++++++++++++++ alembic/README | 1 + alembic/env.py | 98 +++++++++++++++ alembic/script.py.mako | 26 ++++ ...3c_initial_tables_quizzes_and_questions.py | 51 ++++++++ poetry.lock | 40 +++++- pyproject.toml | 4 + 7 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 alembic.ini create mode 100644 alembic/README create mode 100644 alembic/env.py create mode 100644 alembic/script.py.mako create mode 100644 alembic/versions/d2e4b673963c_initial_tables_quizzes_and_questions.py diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..5d23107 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,114 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = alembic + +# 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 +# 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 alembic/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:alembic/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 + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix 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/alembic/README b/alembic/README new file mode 100644 index 0000000..e0d0858 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration with an async dbapi. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..6f8822c --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,98 @@ +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context +from app.core.settings import get_settings +from app.models import * # noqa: F403 +from app.models.database import Base + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# 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) + +# update database URL +settings = get_settings() +config.set_main_option("sqlalchemy.url", settings.get_db_url()) + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata + +# all models must have been previously imported (from app.models import *) +target_metadata = Base.metadata + +# 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 do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..fbc4b07 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/d2e4b673963c_initial_tables_quizzes_and_questions.py b/alembic/versions/d2e4b673963c_initial_tables_quizzes_and_questions.py new file mode 100644 index 0000000..56717d7 --- /dev/null +++ b/alembic/versions/d2e4b673963c_initial_tables_quizzes_and_questions.py @@ -0,0 +1,51 @@ +"""Initial tables (quizzes and questions) + +Revision ID: d2e4b673963c +Revises: +Create Date: 2023-11-26 00:32:14.892163 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'd2e4b673963c' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('quizzes', + sa.Column('title', sa.String(length=128), nullable=False), + sa.Column('description', sa.String(length=512), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_quizzes_title'), 'quizzes', ['title'], unique=False) + op.create_table('questions', + sa.Column('quiz_id', sa.Integer(), nullable=False), + sa.Column('content', sa.String(length=256), nullable=False), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('points', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['quiz_id'], ['quizzes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('questions') + op.drop_index(op.f('ix_quizzes_title'), table_name='quizzes') + op.drop_table('quizzes') + # ### end Alembic commands ### diff --git a/poetry.lock b/poetry.lock index 344ea55..585e67d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -15,6 +15,25 @@ files = [ dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"] docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"] +[[package]] +name = "alembic" +version = "1.12.1" +description = "A database migration tool for SQLAlchemy." +optional = false +python-versions = ">=3.7" +files = [ + {file = "alembic-1.12.1-py3-none-any.whl", hash = "sha256:47d52e3dfb03666ed945becb723d6482e52190917fdb47071440cfdba05d92cb"}, + {file = "alembic-1.12.1.tar.gz", hash = "sha256:bca5877e9678b454706347bc10b97cb7d67f300320fa5c3a94423e8266e2823f"}, +] + +[package.dependencies] +Mako = "*" +SQLAlchemy = ">=1.3.0" +typing-extensions = ">=4" + +[package.extras] +tz = ["python-dateutil"] + [[package]] name = "annotated-types" version = "0.6.0" @@ -686,6 +705,25 @@ win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} [package.extras] dev = ["Sphinx (==7.2.5)", "colorama (==0.4.5)", "colorama (==0.4.6)", "exceptiongroup (==1.1.3)", "freezegun (==1.1.0)", "freezegun (==1.2.2)", "mypy (==v0.910)", "mypy (==v0.971)", "mypy (==v1.4.1)", "mypy (==v1.5.1)", "pre-commit (==3.4.0)", "pytest (==6.1.2)", "pytest (==7.4.0)", "pytest-cov (==2.12.1)", "pytest-cov (==4.1.0)", "pytest-mypy-plugins (==1.9.3)", "pytest-mypy-plugins (==3.0.0)", "sphinx-autobuild (==2021.3.14)", "sphinx-rtd-theme (==1.3.0)", "tox (==3.27.1)", "tox (==4.11.0)"] +[[package]] +name = "mako" +version = "1.3.0" +description = "A super-fast templating language that borrows the best ideas from the existing templating languages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Mako-1.3.0-py3-none-any.whl", hash = "sha256:57d4e997349f1a92035aa25c17ace371a4213f2ca42f99bee9a602500cfd54d9"}, + {file = "Mako-1.3.0.tar.gz", hash = "sha256:e3a9d388fd00e87043edbe8792f45880ac0114e9c4adc69f6e9bfb2c55e3b11b"}, +] + +[package.dependencies] +MarkupSafe = ">=0.9.2" + +[package.extras] +babel = ["Babel"] +lingua = ["lingua"] +testing = ["pytest"] + [[package]] name = "markupsafe" version = "2.1.3" @@ -1874,4 +1912,4 @@ dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"] [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "d8a45f53b0ae03002e4116b96e5aaabdc0d799476510d34bcbb98e1d5470bf59" +content-hash = "60e0ff2a0a3e2d9627b66e813b0e8be00e68d00ac34fc9ba90e49a92b2bc6575" diff --git a/pyproject.toml b/pyproject.toml index 2d8acef..46264a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,7 @@ # Ruff configuration +[tool.ruff] +exclude = ["alembic/*.py"] + [tool.ruff.lint] select = [ # pycodestyle @@ -47,6 +50,7 @@ sqlalchemy = "^2.0.23" pydantic-settings = "^2.0.3" asyncpg = "^0.29.0" loguru = "^0.7.2" +alembic = "^1.12.1" [tool.poetry.group.dev.dependencies] ruff = "^0.1.3" From 5d8ab30e7a8566ff10693466834ebdf1dcfaf9e8 Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Sun, 26 Nov 2023 01:05:03 -0300 Subject: [PATCH 4/6] Update Dockerfile to use Alembic migrations --- Dockerfile | 8 +++++++- app/main.py | 1 + docker-compose.yml | 5 +++-- start_server.sh | 5 +++++ 4 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 start_server.sh diff --git a/Dockerfile b/Dockerfile index 29d9c95..4aaac22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,11 @@ COPY ./poetry.lock /code/poetry.lock RUN poetry install RUN poetry self add 'poethepoet[poetry_plugin]' +# copy Alembic files to create/migrate DB +COPY alembic.ini /code +COPY alembic /code/alembic +COPY start_server.sh /code + # copy the app inside the /code directory COPY ./app /code/app @@ -23,4 +28,5 @@ ENV PYTHONPATH /code EXPOSE 8000 # run command to start Uvicorn server -CMD ["poetry", "poe", "start"] +# CMD ["poetry", "poe", "start"] +CMD [ "/bin/bash", "start_server.sh"] diff --git a/app/main.py b/app/main.py index 704af3f..b17ba11 100644 --- a/app/main.py +++ b/app/main.py @@ -19,6 +19,7 @@ asyncio.run(init_db()) reload = True else: + logger.info("Not creating database tables") reload = False uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=reload) diff --git a/docker-compose.yml b/docker-compose.yml index c36be57..41e24bf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,8 +9,9 @@ services: - 8000:8000 env_file: - ./.env - # environment: - # - USE_ALEMBIC=true + environment: + - USE_ALEMBIC=true + - ENVIRONMENT=prod depends_on: - postgres postgres: diff --git a/start_server.sh b/start_server.sh new file mode 100644 index 0000000..4a1bd65 --- /dev/null +++ b/start_server.sh @@ -0,0 +1,5 @@ +poetry run alembic upgrade head + +USE_ALEMBIC=true + +poetry poe start From 7feaa5f0b85b07f74575a96841ed90628bf02e1d Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Sun, 26 Nov 2023 19:05:52 -0300 Subject: [PATCH 5/6] Move question tests to test_question.py --- app/tests/api/test_question.py | 91 +++++++++++++++++++++++++++++++++- app/tests/api/test_quiz.py | 85 ------------------------------- 2 files changed, 89 insertions(+), 87 deletions(-) diff --git a/app/tests/api/test_question.py b/app/tests/api/test_question.py index c6925ca..9ece9ff 100644 --- a/app/tests/api/test_question.py +++ b/app/tests/api/test_question.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import pytest +from dirty_equals import IsDatetime from fastapi import status from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession @@ -8,10 +9,94 @@ import app.models as models from app.schemas import QuestionType from app.tests.factories.question_factory import QuestionFactory +from app.tests.factories.quiz_factory import QuizFactory + + +@pytest.mark.parametrize("cases", ["default", "custom"]) +async def test_create_question( + client: AsyncClient, db_session: AsyncSession, cases: str +): + # questions must be associated to an existing quiz + quiz = await QuizFactory.create() + question_data = {"quiz_id": quiz.id, "content": "What is the question?"} + + if cases == "custom": + question_data["type"] = QuestionType.multiple_choice + question_data["points"] = 4 + + response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) + + assert response.status_code == status.HTTP_201_CREATED + + if cases == "default": + question_data["type"] = QuestionType.open + question_data["points"] = 1 + + created_question = response.json() + for key in question_data: + assert created_question[key] == question_data[key] + + # created_at/updated_at should be close to the current time + for key in ["created_at", "updated_at"]: + assert created_question[key] == IsDatetime( + approx=datetime.utcnow(), delta=2, iso_string=True + ) + + # check quiz exists in database + db_question = await models.Question.get(db=db_session, id=created_question["id"]) + assert db_question + for key in question_data: + assert question_data[key] == getattr(db_question, key) + + +async def test_create_question_no_quiz(client: AsyncClient, db_session: AsyncSession): + question_data = {"content": "What is the question?"} + response = await client.post("/api/quiz/123/questions", json=question_data) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +async def test_create_question_invalid_type( + client: AsyncClient, db_session: AsyncSession +): + quiz = await QuizFactory.create() + question_data = { + "quiz_id": quiz.id, + "content": "What is the question?", + "type": "invalid", + } + response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) + + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + +async def test_questions(client: AsyncClient, db_session: AsyncSession): + quiz = await QuizFactory.create() + questions = await QuestionFactory.create_batch(5, quiz=quiz) + + response = await client.get(f"/api/quiz/{quiz.id}/questions") + + assert response.status_code == status.HTTP_200_OK + + returned_questions = response.json() + assert len(returned_questions) == len(questions) + + # sort questions by id so they can be iterated simultaneously + questions = sorted(questions, key=lambda d: d.id) + returned_questions = sorted(returned_questions, key=lambda d: d["id"]) + + for created, returned in zip(questions, returned_questions): + for key in returned: + if key in ["created_at", "updated_at"]: + assert returned[key] == IsDatetime( + approx=getattr(created, key), delta=0, iso_string=True + ) + else: + assert returned[key] == getattr(created, key) @pytest.mark.parametrize("cases", ["found", "not_found"]) -async def test_get_quiz(client: AsyncClient, db_session: AsyncSession, cases: str): +async def test_get_question(client: AsyncClient, db_session: AsyncSession, cases: str): question_id = 4 if cases == "found": created_question = await QuestionFactory.create(id=question_id) @@ -31,7 +116,9 @@ async def test_get_quiz(client: AsyncClient, db_session: AsyncSession, cases: st @pytest.mark.parametrize("cases", ["found", "not_found", "partial_update", "invalid"]) -async def test_update_quiz(client: AsyncClient, db_session: AsyncSession, cases: str): +async def test_update_question( + client: AsyncClient, db_session: AsyncSession, cases: str +): question_id = 4 if cases != "not_found": await QuestionFactory.create( diff --git a/app/tests/api/test_quiz.py b/app/tests/api/test_quiz.py index fa12963..afd602c 100644 --- a/app/tests/api/test_quiz.py +++ b/app/tests/api/test_quiz.py @@ -7,8 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession import app.models as models -from app.schemas import QuestionType -from app.tests.factories.question_factory import QuestionFactory from app.tests.factories.quiz_factory import QuizFactory @@ -162,86 +160,3 @@ async def test_delete_quiz(client: AsyncClient, db_session: AsyncSession, cases: # check quiz was deleted from database db_quiz = await models.Quiz.get(db=db_session, id=quiz_id) assert not db_quiz - - -@pytest.mark.parametrize("cases", ["default", "custom"]) -async def test_create_question( - client: AsyncClient, db_session: AsyncSession, cases: str -): - # questions must be associated to an existing quiz - quiz = await QuizFactory.create() - question_data = {"quiz_id": quiz.id, "content": "What is the question?"} - - if cases == "custom": - question_data["type"] = QuestionType.multiple_choice - question_data["points"] = 4 - - response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) - - assert response.status_code == status.HTTP_201_CREATED - - if cases == "default": - question_data["type"] = QuestionType.open - question_data["points"] = 1 - - created_question = response.json() - for key in question_data: - assert created_question[key] == question_data[key] - - # created_at/updated_at should be close to the current time - for key in ["created_at", "updated_at"]: - assert created_question[key] == IsDatetime( - approx=datetime.utcnow(), delta=2, iso_string=True - ) - - # check quiz exists in database - db_question = await models.Question.get(db=db_session, id=created_question["id"]) - assert db_question - for key in question_data: - assert question_data[key] == getattr(db_question, key) - - -async def test_create_question_no_quiz(client: AsyncClient, db_session: AsyncSession): - question_data = {"content": "What is the question?"} - response = await client.post("/api/quiz/123/questions", json=question_data) - - assert response.status_code == status.HTTP_404_NOT_FOUND - - -async def test_create_question_invalid_type( - client: AsyncClient, db_session: AsyncSession -): - quiz = await QuizFactory.create() - question_data = { - "quiz_id": quiz.id, - "content": "What is the question?", - "type": "invalid", - } - response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) - - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY - - -async def test_questions(client: AsyncClient, db_session: AsyncSession): - quiz = await QuizFactory.create() - questions = await QuestionFactory.create_batch(5, quiz=quiz) - - response = await client.get(f"/api/quiz/{quiz.id}/questions") - - assert response.status_code == status.HTTP_200_OK - - returned_questions = response.json() - assert len(returned_questions) == len(questions) - - # sort questions by id so they can be iterated simultaneously - questions = sorted(questions, key=lambda d: d.id) - returned_questions = sorted(returned_questions, key=lambda d: d["id"]) - - for created, returned in zip(questions, returned_questions): - for key in returned: - if key in ["created_at", "updated_at"]: - assert returned[key] == IsDatetime( - approx=getattr(created, key), delta=0, iso_string=True - ) - else: - assert returned[key] == getattr(created, key) From a59ba17d85e5d4ce4e9b16a87e4c9c9d29468d61 Mon Sep 17 00:00:00 2001 From: Matias Brignone Date: Sun, 26 Nov 2023 19:19:03 -0300 Subject: [PATCH 6/6] Add testcase to check that deleting a quiz also deletes its questions --- app/models/question.py | 7 ++++++- app/tests/api/test_quiz.py | 20 ++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/models/question.py b/app/models/question.py index 5d2467e..0abb80d 100644 --- a/app/models/question.py +++ b/app/models/question.py @@ -1,7 +1,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Self -from sqlalchemy import DateTime, ForeignKey, Integer, String +from sqlalchemy import DateTime, ForeignKey, Integer, String, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.sql import func @@ -42,3 +42,8 @@ async def create(cls, db: AsyncSession, question: QuestionCreate) -> Self: await db.refresh(new_question) return new_question + + @classmethod + async def get_by_quiz_id(cls, db: AsyncSession, quiz_id: int) -> list[Self]: + result = await db.execute(select(cls).where(cls.quiz_id == quiz_id)) + return list(result.scalars().all()) diff --git a/app/tests/api/test_quiz.py b/app/tests/api/test_quiz.py index afd602c..40c6f49 100644 --- a/app/tests/api/test_quiz.py +++ b/app/tests/api/test_quiz.py @@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession import app.models as models +from app.tests.factories.question_factory import QuestionFactory from app.tests.factories.quiz_factory import QuizFactory @@ -160,3 +161,22 @@ async def test_delete_quiz(client: AsyncClient, db_session: AsyncSession, cases: # check quiz was deleted from database db_quiz = await models.Quiz.get(db=db_session, id=quiz_id) assert not db_quiz + + +async def test_delete_quiz_delete_questions( + client: AsyncClient, db_session: AsyncSession +): + quiz_id = 4 + quiz = await QuizFactory.create(id=quiz_id) + await QuestionFactory.create_batch(5, quiz=quiz) + + response = await client.delete(f"/api/quiz/{quiz_id}") + + assert response.status_code == status.HTTP_204_NO_CONTENT + + # check quiz was deleted from database + db_quiz = await models.Quiz.get(db=db_session, id=quiz_id) + assert not db_quiz + + db_questions = await models.Question.get_by_quiz_id(db=db_session, quiz_id=quiz_id) + assert len(db_questions) == 0