diff --git a/README.md b/README.md index 3ae0846..4f8e96b 100644 --- a/README.md +++ b/README.md @@ -37,3 +37,10 @@ #### Miscs Dev-deps - `djhtml`: Generate jinja templates html for emails. + +### Backend Technical Write-up + +#### Difference between database schema and database model + +- A schema defines the structure of data within the database. +- A model is a class that can be represented as rows in the database, i.e ID row, age row as class member. diff --git a/backend/development.env b/backend/development.env index 78fb7ff..d330ba2 100644 --- a/backend/development.env +++ b/backend/development.env @@ -2,6 +2,7 @@ TODO_BASE_URL="localhost:5050" TODO_DEBUG=true TODO_SECRET_KEY="secret key" TODO_QUART_DB_DATABASE_URL="postgresql://todo:todo@0.0.0.0:5432/todo" +TODO_QUART_DB_DATA_PATH="migrations/data.py" # disable for when we not using HTTPS TODO_QUART_AUTH_COOKIE_SECURE=false diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 0826eea..022973a 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -53,6 +53,10 @@ addopts = "--showlocals" asyncio_mode = "auto" pythonpath = ["src"] +[[tool.mypy.overrides]] +module =["h11"] +ignore_missing_imports = true + [tool.pdm.scripts] format-black = "black src/ tests/" format-djhtml = "djhtml src/backend/templates -t 2 --in-place" diff --git a/backend/src/backend/migrations/0.py b/backend/src/backend/migrations/0.py new file mode 100644 index 0000000..b764a46 --- /dev/null +++ b/backend/src/backend/migrations/0.py @@ -0,0 +1,31 @@ +from quart_db import Connection + + +async def migrate(connection: Connection) -> None: + await connection.execute( + """CREATE TABLE members ( + id INT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + created TIMESTAMP NOT NULL DEFAULT now(), + email TEXT NOT NULL, + email_verified TIMESTAMP, + password_hash TEXT NOT NULL + )""", + ) + await connection.execute( + """CREATE UNIQUE INDEX members_unique_email_idx + ON members (LOWER(email) + )""" + ) + await connection.execute( + """CREATE TABLE todos ( + id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + complete BOOLEAN NOT NULL DEFAULT FALSE, + due TIMESTAMPTZ, + member_id INT NOT NULL REFERENCES members(id), + task TEXT NOT NULL + )""", + ) + + +async def valid_migration(connection: Connection) -> bool: + return True diff --git a/backend/src/backend/migrations/__init__.py b/backend/src/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/migrations/data.py b/backend/src/backend/migrations/data.py new file mode 100644 index 0000000..4d3bb63 --- /dev/null +++ b/backend/src/backend/migrations/data.py @@ -0,0 +1,14 @@ +from quart_db import Connection + + +async def execute(connection: Connection) -> None: + await connection.execute( + """INSERT INTO members (email, password_hash) + VALUES ('member@todo.minhtrannhat.com', + '$2b$14$6yXjNza30kPCg3LhzZJfqeCWOLM.zyTiQFD4rdWlFHBTfYzzKJMJe' + )""" + ) + await connection.execute( + """INSERT INTO todos (member_id, task) + VALUES (1, 'Test Task')""" + ) diff --git a/backend/src/backend/models/__init__.py b/backend/src/backend/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/backend/models/member.py b/backend/src/backend/models/member.py new file mode 100644 index 0000000..7baa8e6 --- /dev/null +++ b/backend/src/backend/models/member.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from datetime import datetime + +from quart_db import Connection + + +@dataclass +class Member: + id: int + email: str + password_hash: str + created: datetime + email_verified: datetime | None + + +async def select_member_by_email(db: Connection, email: str) -> Member | None: + result = await db.fetch_one( + """SELECT id, email, password_hash, created, email_verified + FROM members + WHERE LOWER(email) = LOWER(:email)""", + {"email": email}, + ) + return None if result is None else Member(**result) + + +async def select_member_by_id(db: Connection, id: int) -> Member | None: + result = await db.fetch_one( + """SELECT id, email, password_hash, created, email_verified + FROM members + WHERE id = :id""", + {"id": id}, + ) + return None if result is None else Member(**result) + + +async def insert_member(db: Connection, email: str, password_hash: str) -> Member: + result = await db.fetch_one( + """INSERT INTO members (email, password_hash) + VALUES (:email, :password_hash) + RETURNING id, email, password_hash, created, + email_verified""", + {"email": email, "password_hash": password_hash}, + ) + return Member(**result) + + +async def update_member_password(db: Connection, id: int, password_hash: str) -> None: + await db.execute( + """UPDATE members + SET password_hash = :password_hash + WHERE id = :id""", + {"id": id, "password_hash": password_hash}, + ) + + +async def update_member_email_verified(db: Connection, id: int) -> None: + await db.execute( + "UPDATE members SET email_verified = now() WHERE id = :id", + {"id": id}, + ) diff --git a/backend/src/backend/models/todo.py b/backend/src/backend/models/todo.py new file mode 100644 index 0000000..4d9c23e --- /dev/null +++ b/backend/src/backend/models/todo.py @@ -0,0 +1,102 @@ +from dataclasses import dataclass +from datetime import datetime + +from pydantic import constr +from quart_db import Connection + + +@dataclass +class Todo: + complete: bool + due: datetime | None + id: int + task: constr(strip_whitespace=True, min_length=1) # type: ignore + + +async def select_todos( + connection: Connection, + member_id: int, + complete: bool | None = None, +) -> list[Todo]: + if complete is None: + query = """SELECT id, complete, due, task + FROM todos + WHERE member_id = :member_id""" + values = {"member_id": member_id} + else: + query = """SELECT id, complete, due, task + FROM todos + WHERE member_id = :member_id + AND complete = :complete""" + values = {"member_id": member_id, "complete": complete} + return [Todo(**row) async for row in connection.iterate(query, values)] + + +async def select_todo( + connection: Connection, + id: int, + member_id: int, +) -> Todo | None: + result = await connection.fetch_one( + """SELECT id, complete, due, task + FROM todos + WHERE id = :id AND member_id = :member_id""", + {"id": id, "member_id": member_id}, + ) + return None if result is None else Todo(**result) + + +async def insert_todo( + connection: Connection, + member_id: int, + task: str, + complete: bool, + due: datetime | None, +) -> Todo: + result = await connection.fetch_one( + """INSERT INTO todos (complete, due, member_id, task) + VALUES (:complete, :due, :member_id, :task) + RETURNING id, complete, due, task""", + { + "member_id": member_id, + "task": task, + "complete": complete, + "due": due, + }, + ) + return Todo(**result) + + +async def update_todo( + connection: Connection, + id: int, + member_id: int, + task: str, + complete: bool, + due: datetime | None, +) -> Todo | None: + result = await connection.fetch_one( + """UPDATE todos + SET complete = :complete, due = :due, task = :task + WHERE id = :id AND member_id = :member_id + RETURNING id, complete, due, task""", + { + "id": id, + "member_id": member_id, + "task": task, + "complete": complete, + "due": due, + }, + ) + return None if result is None else Todo(**result) + + +async def delete_todo( + connection: Connection, + id: int, + member_id: int, +) -> None: + await connection.execute( + "DELETE FROM todos WHERE id = :id AND member_id = :member_id", + {"id": id, "member_id": member_id}, + ) diff --git a/backend/src/backend/run.py b/backend/src/backend/run.py index a48b4be..05da25e 100644 --- a/backend/src/backend/run.py +++ b/backend/src/backend/run.py @@ -1,7 +1,7 @@ from quart import Quart, ResponseReturnValue import os -from subprocess import call # no sec +from subprocess import call # nosec from urllib.parse import urlparse # Each blueprint is a logical collection of features in our web app diff --git a/backend/testing.env b/backend/testing.env index c0baf19..3020fde 100644 --- a/backend/testing.env +++ b/backend/testing.env @@ -3,6 +3,7 @@ TODO_DEBUG=true TODO_SECRET_KEY="secret key" TODO_TESTING=true TODO_QUART_DB_DATABASE_URL="postgresql://todo_test:todo_test@0.0.0.0:5432/todo_test" +TODO_QUART_DB_DATA_PATH="migrations/data.py" # disable for when we not using HTTPS TODO_QUART_AUTH_COOKIE_SECURE=false diff --git a/backend/tests/models/__init__.py b/backend/tests/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/models/test_member.py b/backend/tests/models/test_member.py new file mode 100644 index 0000000..ab9b875 --- /dev/null +++ b/backend/tests/models/test_member.py @@ -0,0 +1,17 @@ +import pytest +from asyncpg.exceptions import UniqueViolationError # type: ignore +from quart_db import Connection + +from backend.models.member import insert_member, select_member_by_email + + +async def test_insert_member(connection: Connection) -> None: + await insert_member(connection, "casing@todo.minhtrannhat.com", "") + with pytest.raises(UniqueViolationError): + await insert_member(connection, "Casing@todo.minhtrannhat.com", "") + + +async def test_select_member_by_email(connection: Connection) -> None: + await insert_member(connection, "casing@todo.minhtrannhat.com", "") + member = await select_member_by_email(connection, "Casing@todo.minhtrannhat.com") + assert member is not None diff --git a/backend/tests/models/test_todo.py b/backend/tests/models/test_todo.py new file mode 100644 index 0000000..408ce32 --- /dev/null +++ b/backend/tests/models/test_todo.py @@ -0,0 +1,31 @@ +import pytest +from quart_db import Connection + +from backend.models.todo import delete_todo, insert_todo, select_todo, update_todo + + +@pytest.mark.parametrize( + "member_id, deleted", + [(1, True), (2, False)], +) +async def test_delete_todo( + connection: Connection, member_id: int, deleted: bool +) -> None: + todo = await insert_todo(connection, 1, "Task", False, None) + await delete_todo(connection, todo.id, member_id) + new_todo = await select_todo(connection, todo.id, 1) + assert (new_todo is None) is deleted + + +@pytest.mark.parametrize( + "member_id, complete", + [(1, True), (2, False)], +) +async def test_update_todo( + connection: Connection, member_id: int, complete: bool +) -> None: + todo = await insert_todo(connection, 1, "Task", False, None) + await update_todo(connection, todo.id, member_id, "Task", True, None) + new_todo = await select_todo(connection, todo.id, 1) + assert new_todo is not None + assert new_todo.complete is complete