Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Answer Options #5

Merged
merged 6 commits into from
Dec 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
128 changes: 128 additions & 0 deletions api_design.md
Original file line number Diff line number Diff line change
@@ -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).
44 changes: 44 additions & 0 deletions app/api/endpoints/question.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
16 changes: 9 additions & 7 deletions app/api/endpoints/quiz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions app/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# ruff: noqa: F401 (unused imports)

from .answer_options import AnswerOption
from .question import Question
from .quiz import Quiz
38 changes: 38 additions & 0 deletions app/models/answer_options.py
Original file line number Diff line number Diff line change
@@ -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
17 changes: 15 additions & 2 deletions app/models/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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(
Expand All @@ -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()
9 changes: 8 additions & 1 deletion app/models/quiz.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions app/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions app/schemas/answer_options.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/schemas/question.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

from pydantic import BaseModel, ConfigDict

from app.schemas import AnswerOptionReturn


class QuestionType(str, Enum):
open = "open"
Expand Down Expand Up @@ -36,3 +38,7 @@ class QuestionReturn(QuestionBase):
quiz_id: int
created_at: datetime
updated_at: datetime


class QuestionWithOptions(QuestionReturn):
answer_options: list[AnswerOptionReturn]
Loading