diff --git a/.github/workflows/python-app.yml b/.github/workflows/python-app.yml index 7df268d..3ae2305 100644 --- a/.github/workflows/python-app.yml +++ b/.github/workflows/python-app.yml @@ -29,6 +29,9 @@ jobs: - name: Lint with Ruff run: | poetry poe lint + - name: Type check with Mypy + run: | + poetry poe typecheck - name: Test with Pytest run: | poetry poe test diff --git a/api_design.md b/api_design.md new file mode 100644 index 0000000..4e3de34 --- /dev/null +++ b/api_design.md @@ -0,0 +1,128 @@ +# QUIZ APP API DESIGN + +# Endpoints + +## User Authentication and Management +**POST /users/register: Register a new user.** + +**POST /users/login: Authenticate a user.** + +**GET /users/{user_id}: Retrieve user information.** + +**PUT /users/{user_id}: Update user profile.** + +**DELETE /users/{user_id}: Delete user account.** + +## Quiz Management +**POST /quizzes: Create a new quiz.** + +**GET /quizzes/{quiz_id}: Retrieve details of a specific quiz.** + +**PUT /quizzes/{quiz_id}: Update a quiz.** + +**DELETE /quizzes/{quiz_id}: Delete a quiz.** + +## Question Management +**POST /quizzes/{quiz_id}/questions: Add questions to a quiz.** + +**GET /quizzes/{quiz_id}/questions: Get questions associated to a quiz.** + +**GET /questions/{question_id}: Get a specific question.** + +**PUT /questions/{question_id}: Update a specific question.** + +**DELETE /questions/{question_id}: Delete a specific question.** + +## Answer Options Management + +**POST /questions/{question_id}/options: Add a new answer option to an existing question.** + +**GET /questions/{question_id}/options: Get answer optiond associated to a question.** + +**GET /options/{option_id}: Get an existing answer option.** + +**PUT /options/{option_id}: Update an existing answer option.** + +**DELETE /options/{option_id}: Delete an existing answer option.** + +## Quiz Participation +**POST /quizzes/{quiz_id}/answers: Submit answers to a quiz.** + +**POST /questions/{question_id}/answers: Submit answer to a question.** + +**GET /quizzes/{quiz_id}/results: Get the results of a quiz attempt.** + +**GET /quizzes/{quiz_id}/leaderboard: Show the top scores for a specific quiz.** + +**GET /users/{user_id}/quizzes/attempts: Allow users to review their past quiz attempts.** + +# Database Schemas + +## Users Table +* Table Name: **users** +* Description: Stores information about the users. +* Columns: + * id (Primary Key): Unique identifier for each user. + * username (String, Unique): User's chosen username. + * email (String, Unique): User's email address. + * password_hash (String): Hashed password for security. + * created_at (DateTime): Date and time of registration. + * last_login (DateTime): Date and time of last login. + +## Quizzes Table +* Table Name: **quizzes** +* Description: Stores basic information about quizzes. +* Columns: + * id (Primary Key): Unique identifier for each quiz. + * creator_id (Foreign Key to users): The user who created the quiz. + * title (String): Title of the quiz. + * description (String): Description or summary of the quiz. + * created_at (DateTime): When the quiz was created. + * updated_at (DateTime): Last update time. + +## Questions Table +* Table Name: **questions** +* Description: Stores questions for each quiz. +* Columns: + * id (Primary Key): Unique identifier for each question. + * quiz_id (Foreign Key to quizzes): The quiz this question belongs to. + * content (String): The actual question text. + * type (String): The question type (multiple choice, true/false, open). + * points (Integer): The question points (to compute quiz score). + * created_at (DateTime): When the question was created. + +## Answer Options Table +* Table Name: **answer_options** +* Description: Stores possible answers for each question (for multiple-choice questions). +* Columns: + * id (Primary Key): Unique identifier for each option. + * question_id (Foreign Key to questions): The question this option belongs to. + * content (String): The text of the answer option. + * is_correct (Bool): Flag to indicate if this is the correct option + +## Quiz Attempts Table +* Table Name: **quiz_attempts** +* Description: Records each attempt a user makes on a quiz. +* Columns: + * id (Primary Key): Unique identifier for each attempt. + * quiz_id (Foreign Key to quizzes): The quiz attempted. + * user_id (Foreign Key to users): The user who made the attempt. + * score (Integer): The score achieved in the attempt. + * attempted_at (DateTime): When the attempt was made. + +## User Answers Table +* Table Name: **user_answers** +* Description: Stores the answers given by users in each attempt. +* Columns: + * id (Primary Key): Unique identifier for each user answer. + * attempt_id (Foreign Key to quiz_attempts): The attempt this answer is part of. + * question_id (Foreign Key to questions): The question being answered. + * chosen_option_id (Foreign Key to answer_options): The option chosen by the user. + +## Relationships +* Users to Quizzes: One-to-Many (A user can create many quizzes). +* Quizzes to Questions: One-to-Many (A quiz contains many questions). +* Questions to Answer Options: One-to-Many (A question has multiple answer options). +* Users to Quiz Attempts: One-to-Many (A user can attempt many quizzes). +* Quizzes to Quiz Attempts: One-to-Many (A quiz can be attempted many times). +* Quiz Attempts to User Answers: One-to-Many (Each attempt can have multiple answers). diff --git a/app/api/endpoints/question.py b/app/api/endpoints/question.py index 5f777ec..f8150b2 100644 --- a/app/api/endpoints/question.py +++ b/app/api/endpoints/question.py @@ -1,6 +1,7 @@ from typing import Annotated, Any from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.exc import IntegrityError import app.models as models import app.schemas as schemas @@ -64,3 +65,46 @@ async def delete_question( ) -> None: await models.Question.delete(db=db, db_obj=question) # body will be empty when using status code 204 + + +@router.post( + "/{question_id}/options", + response_model=schemas.AnswerOptionReturn, + status_code=status.HTTP_201_CREATED, + summary="Add an answer option to the question", + response_description="The created answer option", +) +async def add_answer_option( + question_id: int, + answer: schemas.AnswerOptionCreate, + db: AsyncSessionDep, +) -> Any: + answer.question_id = question_id + try: + created_answer = await models.AnswerOption.create(db=db, option=answer) + except IntegrityError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Question not found" + ) from exc + + return created_answer + + +@router.get( + "/{question_id}/options", + status_code=status.HTTP_200_OK, + response_model=list[schemas.AnswerOptionReturn], + summary="Get all answer options associated to the question", + response_description="The list of answer options associated to the question", +) +async def get_question_answer_options( + question_id: int, + db: AsyncSessionDep, +) -> Any: + question = await models.Question.get_with_answers(db=db, id=question_id) + if not question: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Question not found" + ) + + return question.answer_options diff --git a/app/api/endpoints/quiz.py b/app/api/endpoints/quiz.py index e756fc0..6705817 100644 --- a/app/api/endpoints/quiz.py +++ b/app/api/endpoints/quiz.py @@ -7,7 +7,7 @@ import app.schemas as schemas from app.models.database import AsyncSessionDep -router = APIRouter(prefix="/quiz", tags=["quiz"]) +router = APIRouter(prefix="/quizzes", tags=["quiz"]) async def get_quiz_from_id(quiz_id: int, db: AsyncSessionDep) -> models.Quiz: @@ -117,9 +117,11 @@ async def create_question_for_quiz( summary="Get all questions associated to the quiz", response_description="The list of questions associated to the quiz", ) -async def get_all_questions_from_quiz( - quiz_id: int, - quiz: Annotated[models.Quiz, Depends(get_quiz_from_id)], - db: AsyncSessionDep, -) -> Any: - return await quiz.awaitable_attrs.questions +async def get_all_questions_from_quiz(quiz_id: int, db: AsyncSessionDep) -> Any: + quiz = await models.Quiz.get_with_questions(db=db, id=quiz_id) + if not quiz: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Quiz not found" + ) + + return quiz.questions diff --git a/app/models/__init__.py b/app/models/__init__.py index 412f9d3..906202c 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,4 +1,5 @@ # ruff: noqa: F401 (unused imports) +from .answer_options import AnswerOption from .question import Question from .quiz import Quiz diff --git a/app/models/answer_options.py b/app/models/answer_options.py new file mode 100644 index 0000000..ca8f4b4 --- /dev/null +++ b/app/models/answer_options.py @@ -0,0 +1,38 @@ +from typing import TYPE_CHECKING, Self + +from sqlalchemy import Boolean, ForeignKey, String +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.database import Base +from app.schemas import AnswerOptionCreate + +if TYPE_CHECKING: + from app.models.question import Question + + +class AnswerOption(Base): + __tablename__ = "answer_options" + + question_id: Mapped[int] = mapped_column(ForeignKey("questions.id")) + content: Mapped[str] = mapped_column(String(256), nullable=False) + is_correct: Mapped[bool] = mapped_column(Boolean, default=False) + + # have to use "Question" to avoid circular dependencies + question: Mapped["Question"] = relationship( + "Question", back_populates="answer_options" + ) + + @classmethod + async def create(cls, db: AsyncSession, option: AnswerOptionCreate) -> Self: + new_option = cls( + question_id=option.question_id, + content=option.content, + is_correct=option.is_correct, + ) + + db.add(new_option) + await db.commit() + await db.refresh(new_option) + + return new_option diff --git a/app/models/question.py b/app/models/question.py index 0abb80d..2e3a86e 100644 --- a/app/models/question.py +++ b/app/models/question.py @@ -3,13 +3,14 @@ from sqlalchemy import DateTime, ForeignKey, Integer, String, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, joinedload, mapped_column, relationship from sqlalchemy.sql import func from app.models.database import Base from app.schemas import QuestionCreate if TYPE_CHECKING: + from app.models.answer_options import AnswerOption from app.models.quiz import Quiz @@ -25,9 +26,13 @@ class Question(Base): ) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True)) - # have to use "Quiz" to avoid circular dependencies + # types as strings (i.e. "Quiz") to avoid circular dependencies quiz: Mapped["Quiz"] = relationship("Quiz", back_populates="questions") + answer_options: Mapped[list["AnswerOption"]] = relationship( + "AnswerOption", back_populates="question", cascade="delete, delete-orphan" + ) + @classmethod async def create(cls, db: AsyncSession, question: QuestionCreate) -> Self: new_question = cls( @@ -47,3 +52,11 @@ async def create(cls, db: AsyncSession, question: QuestionCreate) -> Self: 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()) + + @classmethod + async def get_with_answers(cls, db: AsyncSession, id: int) -> Self | None: + result = await db.execute( + select(cls).where(cls.id == id).options(joinedload(cls.answer_options)) + ) + + return result.scalar() diff --git a/app/models/quiz.py b/app/models/quiz.py index 27e890e..f24d3e5 100644 --- a/app/models/quiz.py +++ b/app/models/quiz.py @@ -3,7 +3,7 @@ from sqlalchemy import DateTime, String, desc, select from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, joinedload, mapped_column, relationship from sqlalchemy.sql import func from app.models.database import Base @@ -47,3 +47,10 @@ async def get_multiple( select(cls).order_by(desc(cls.created_at)).offset(offset).limit(limit) ) return list(result.scalars().all()) + + @classmethod + async def get_with_questions(cls, db: AsyncSession, id: int) -> Self | None: + result = await db.execute( + select(cls).where(cls.id == id).options(joinedload(cls.questions)) + ) + return result.scalar() diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py index 2359bc5..046d91d 100644 --- a/app/schemas/__init__.py +++ b/app/schemas/__init__.py @@ -1,4 +1,5 @@ # ruff: noqa: F401 (unused imports) +from .answer_options import AnswerOptionCreate, AnswerOptionReturn, AnswerOptionUpdate from .question import QuestionCreate, QuestionReturn, QuestionType, QuestionUpdate from .quiz import QuizCreate, QuizReturn, QuizUpdate, QuizWithQuestions diff --git a/app/schemas/answer_options.py b/app/schemas/answer_options.py new file mode 100644 index 0000000..bb17ce8 --- /dev/null +++ b/app/schemas/answer_options.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, ConfigDict + + +class AnswerOptionBase(BaseModel): + content: str + is_correct: bool = False + + model_config = ConfigDict(from_attributes=True) + + +class AnswerOptionCreate(AnswerOptionBase): + question_id: int | None = None + + +class AnswerOptionUpdate(BaseModel): + # not forced to always update all the fields + content: str | None = None + is_correct: bool | None = None + + model_config = ConfigDict(from_attributes=True) + + +class AnswerOptionReturn(AnswerOptionBase): + id: int + question_id: int diff --git a/app/schemas/question.py b/app/schemas/question.py index be15ed8..a5eb659 100644 --- a/app/schemas/question.py +++ b/app/schemas/question.py @@ -3,6 +3,8 @@ from pydantic import BaseModel, ConfigDict +from app.schemas import AnswerOptionReturn + class QuestionType(str, Enum): open = "open" @@ -36,3 +38,7 @@ class QuestionReturn(QuestionBase): quiz_id: int created_at: datetime updated_at: datetime + + +class QuestionWithOptions(QuestionReturn): + answer_options: list[AnswerOptionReturn] diff --git a/app/tests/api/test_answer_options.py b/app/tests/api/test_answer_options.py new file mode 100644 index 0000000..f0dda81 --- /dev/null +++ b/app/tests/api/test_answer_options.py @@ -0,0 +1,63 @@ +import pytest +from fastapi import status +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.tests.factories.answer_options_factory import AnswerOptionFactory +from app.tests.factories.question_factory import QuestionFactory + + +@pytest.mark.parametrize("cases", ["found", "not_found"]) +async def test_create_answer_options( + client: AsyncClient, db_session: AsyncSession, cases: str +): + if cases == "found": + question = await QuestionFactory.create() + question_id = question.id + else: + question_id = 123 + + answer_data = {"content": "This is the answer", "is_correct": True} + response = await client.post( + f"/api/questions/{question_id}/options", json=answer_data + ) + + if cases == "not_found": + assert response.status_code == status.HTTP_404_NOT_FOUND + else: + assert response.status_code == status.HTTP_201_CREATED + created_answer = response.json() + for key in answer_data: + assert created_answer[key] == answer_data[key] + + +@pytest.mark.parametrize("cases", ["found", "not_found"]) +async def test_get_answer_options( + client: AsyncClient, + db_session: AsyncSession, + cases: str, +): + if cases == "found": + question = await QuestionFactory.create() + answer_options = await AnswerOptionFactory.create_batch(5, question=question) + question_id = question.id + else: + question_id = 123 + + response = await client.get(f"/api/questions/{question_id}/options") + + if cases == "not_found": + assert response.status_code == status.HTTP_404_NOT_FOUND + else: + assert response.status_code == status.HTTP_200_OK + + returned_options = response.json() + assert len(returned_options) == len(answer_options) + + # sort questions by id so they can be iterated simultaneously + answer_options = sorted(answer_options, key=lambda d: d.id) + returned_options = sorted(returned_options, key=lambda d: d["id"]) + + for created, returned in zip(answer_options, returned_options): + for key in returned: + assert returned[key] == getattr(created, key) diff --git a/app/tests/api/test_question.py b/app/tests/api/test_question.py index 9ece9ff..a51a882 100644 --- a/app/tests/api/test_question.py +++ b/app/tests/api/test_question.py @@ -24,7 +24,9 @@ async def test_create_question( question_data["type"] = QuestionType.multiple_choice question_data["points"] = 4 - response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) + response = await client.post( + f"/api/quizzes/{quiz.id}/questions", json=question_data + ) assert response.status_code == status.HTTP_201_CREATED @@ -51,7 +53,7 @@ async def test_create_question( 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) + response = await client.post("/api/quizzes/123/questions", json=question_data) assert response.status_code == status.HTTP_404_NOT_FOUND @@ -65,34 +67,44 @@ async def test_create_question_invalid_type( "content": "What is the question?", "type": "invalid", } - response = await client.post(f"/api/quiz/{quiz.id}/questions", json=question_data) + response = await client.post( + f"/api/quizzes/{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 +@pytest.mark.parametrize("cases", ["found", "not_found"]) +async def test_get_questions(client: AsyncClient, db_session: AsyncSession, cases: str): + if cases == "found": + quiz = await QuizFactory.create() + questions = await QuestionFactory.create_batch(5, quiz=quiz) + quiz_id = quiz.id + else: + quiz_id = 123 - returned_questions = response.json() - assert len(returned_questions) == len(questions) + response = await client.get(f"/api/quizzes/{quiz_id}/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"]) + if cases == "not_found": + assert response.status_code == status.HTTP_404_NOT_FOUND + else: + assert response.status_code == status.HTTP_200_OK - 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) + 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"]) diff --git a/app/tests/api/test_quiz.py b/app/tests/api/test_quiz.py index 40c6f49..5b80f3b 100644 --- a/app/tests/api/test_quiz.py +++ b/app/tests/api/test_quiz.py @@ -11,7 +11,6 @@ from app.tests.factories.quiz_factory import QuizFactory -# add test with missing title/description @pytest.mark.parametrize("cases", ["full", "no_title", "no_description"]) async def test_create_quiz(client: AsyncClient, db_session: AsyncSession, cases: str): quiz_data = {"title": "My quiz", "description": "My quiz description"} @@ -20,7 +19,7 @@ async def test_create_quiz(client: AsyncClient, db_session: AsyncSession, cases: elif cases == "no_description": quiz_data.pop("description") - response = await client.post("/api/quiz", json=quiz_data) + response = await client.post("/api/quizzes", json=quiz_data) if cases == "no_title": assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY @@ -53,7 +52,7 @@ async def test_get_quizzes(client: AsyncClient, db_session: AsyncSession, cases: n_quiz = 3 if cases == "few_quizzes" else 30 await QuizFactory.create_batch(n_quiz) - response = await client.get("/api/quiz") + response = await client.get("/api/quizzes") assert response.status_code == status.HTTP_200_OK @@ -79,7 +78,7 @@ async def test_get_quizzes_query_params(client: AsyncClient, db_session: AsyncSe n_quiz = 30 await QuizFactory.create_batch(n_quiz) - response = await client.get("/api/quiz?offset=2&limit=30") + response = await client.get("/api/quizzes?offset=2&limit=30") assert response.status_code == status.HTTP_200_OK @@ -93,7 +92,7 @@ async def test_get_quiz(client: AsyncClient, db_session: AsyncSession, cases: st if cases == "found": created_quiz = await QuizFactory.create(id=quiz_id) - response = await client.get(f"/api/quiz/{quiz_id}") + response = await client.get(f"/api/quizzes/{quiz_id}") if cases == "not_found": assert response.status_code == status.HTTP_404_NOT_FOUND @@ -120,7 +119,7 @@ async def test_update_quiz(client: AsyncClient, db_session: AsyncSession, cases: quiz_data["description"] = "New description" before_update = datetime.utcnow() - timedelta(seconds=5) - response = await client.put(f"/api/quiz/{quiz_id}", json=quiz_data) + response = await client.put(f"/api/quizzes/{quiz_id}", json=quiz_data) if cases == "not_found": assert response.status_code == status.HTTP_404_NOT_FOUND @@ -151,7 +150,7 @@ async def test_delete_quiz(client: AsyncClient, db_session: AsyncSession, cases: if cases == "found": await QuizFactory.create(id=quiz_id) - response = await client.delete(f"/api/quiz/{quiz_id}") + response = await client.delete(f"/api/quizzes/{quiz_id}") if cases == "not_found": assert response.status_code == status.HTTP_404_NOT_FOUND @@ -170,7 +169,7 @@ async def test_delete_quiz_delete_questions( quiz = await QuizFactory.create(id=quiz_id) await QuestionFactory.create_batch(5, quiz=quiz) - response = await client.delete(f"/api/quiz/{quiz_id}") + response = await client.delete(f"/api/quizzes/{quiz_id}") assert response.status_code == status.HTTP_204_NO_CONTENT diff --git a/app/tests/conftest.py b/app/tests/conftest.py index 7d31359..9fca0cf 100644 --- a/app/tests/conftest.py +++ b/app/tests/conftest.py @@ -10,10 +10,11 @@ from app.main import app from app.models.database import AsyncSessionLocal, Base, async_engine, get_session +from app.tests.factories.answer_options_factory import AnswerOptionFactory from app.tests.factories.question_factory import QuestionFactory from app.tests.factories.quiz_factory import QuizFactory -factory_list = [QuizFactory, QuestionFactory] +factory_list = [AnswerOptionFactory, QuizFactory, QuestionFactory] @pytest.fixture(scope="session") diff --git a/app/tests/factories/answer_options_factory.py b/app/tests/factories/answer_options_factory.py new file mode 100644 index 0000000..6b45a2f --- /dev/null +++ b/app/tests/factories/answer_options_factory.py @@ -0,0 +1,17 @@ +import factory # type: ignore + +from app.models import AnswerOption +from app.tests.factories.base_factory import BaseFactory +from app.tests.factories.question_factory import QuestionFactory + + +class AnswerOptionFactory(BaseFactory[AnswerOption]): + id = factory.Sequence(lambda x: x) + content = factory.Faker("sentence") + is_correct = factory.Iterator([True, False]) + + question = factory.SubFactory(QuestionFactory) + + class Meta: + model = AnswerOption + sqlalchemy_session_persistence = "commit" diff --git a/pyproject.toml b/pyproject.toml index 46264a3..5326283 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,4 +76,5 @@ start = "python app/main.py" test = "pytest app/tests/ -v --cov --cov-report=term-missing" lint = "ruff check ." format = "ruff format ." +typecheck = "mypy ./app" precommit = "pre-commit run --all-files"