Skip to content

Commit

Permalink
Merge branch 'main' into README-update
Browse files Browse the repository at this point in the history
  • Loading branch information
deepansh96 authored Jun 9, 2022
2 parents 7fe4b8f + 66acef0 commit cbe8c89
Show file tree
Hide file tree
Showing 14 changed files with 323 additions and 71 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Quiz Backend

Backend for the Avanti Quiz Engine created using FastAPI and MongoDB!
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![codecov](https://codecov.io/gh/avantifellows/quiz-backend/branch/main/graph/badge.svg)](https://codecov.io/gh/avantifellows/quiz-backend)
[![Discord](https://img.shields.io/discord/717975833226248303.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2&style=flat-square)](https://discord.gg/29qYD7fZtZ)

The backend for a generic mobile-friendly quiz engine created using FastAPI and MongoDB! The frontend can be found [here](https://github.com/avantifellows/quiz-frontend).

## Installation

Expand Down
3 changes: 2 additions & 1 deletion app/main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routers import questions, quizzes, session_answers, sessions
from routers import questions, quizzes, session_answers, sessions, organizations
from mangum import Mangum

app = FastAPI()
Expand All @@ -22,5 +22,6 @@
app.include_router(quizzes.router)
app.include_router(sessions.router)
app.include_router(session_answers.router)
app.include_router(organizations.router)

handler = Mangum(app)
51 changes: 38 additions & 13 deletions app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,27 @@
from pydantic import BaseModel, Field
from schemas import QuestionType, PyObjectId, NavigationMode, QuizLanguage, QuizType

answerType = Union[List[int], str, None]
answerType = Union[List[int], float, int, str, None]


class Organization(BaseModel):
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
name: str

class Config:
allow_population_by_field_name = True
arbitrary_types_allowed = True
json_encoders = {ObjectId: str}
schema_extra = {"example": {"name": "Avanti Fellows"}}


class OrganizationResponse(Organization):
name: str

class Config:
allow_population_by_field_name = True
arbitrary_types_allowed = True
schema_extra = {"example": {"name": "Avanti Fellows"}}


class Image(BaseModel):
Expand All @@ -28,18 +48,18 @@ class QuizTimeLimit(BaseModel):


class QuestionMetadata(BaseModel):
grade: str
subject: str
chapter: str
topic: str
competency: List[str]
difficulty: str
grade: Optional[str]
subject: Optional[str]
chapter: Optional[str]
topic: Optional[str]
competency: Optional[List[str]]
difficulty: Optional[str]


class QuizMetadata(BaseModel):
quiz_type: QuizType
grade: str
subject: str
grade: Optional[str]
subject: Optional[str]
chapter: Optional[str]
topic: Optional[str]

Expand All @@ -54,11 +74,13 @@ class Question(BaseModel):
image: Optional[Image] = None
options: Optional[List[Option]] = []
max_char_limit: Optional[int] = None
correct_answer: Union[List[int], None] = None
correct_answer: Union[List[int], float, int, None] = None
graded: bool = True
marking_scheme: MarkingScheme = None
solution: Optional[List[str]] = []
metadata: QuestionMetadata = None
source: Optional[str] = None
source_id: Optional[str] = None

class Config:
allow_population_by_field_name = True
Expand Down Expand Up @@ -270,6 +292,7 @@ class SessionAnswer(BaseModel):
id: PyObjectId = Field(default_factory=PyObjectId, alias="_id")
question_id: str
answer: answerType = None
visited: Union[bool, None] = False

class Config:
allow_population_by_field_name = True
Expand All @@ -281,10 +304,11 @@ class Config:
class UpdateSessionAnswer(BaseModel):
"""Model for the body of the request that updates a session answer"""

answer: answerType
answer: Optional[answerType]
visited: Optional[Union[bool, None]]

class Config:
schema_extra = {"example": {"answer": [0, 1, 2]}}
schema_extra = {"example": {"answer": [0, 1, 2], "visited": True}}


class SessionAnswerResponse(SessionAnswer):
Expand Down Expand Up @@ -329,7 +353,7 @@ class SessionResponse(Session):
"""Model for the response of any request that returns a session"""

is_first: bool
hasQuizEnded: Optional[bool] = False
has_quiz_ended: Optional[bool] = False
session_answers: List[SessionAnswer]

class Config:
Expand All @@ -339,6 +363,7 @@ class Config:
"user_id": "1234",
"quiz_id": "5678",
"is_first": True,
"has_quiz_ended": False,
"session_answers": [
{
"_id": "1030c00d03",
Expand Down
52 changes: 52 additions & 0 deletions app/routers/organizations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from fastapi import APIRouter, status, HTTPException
from settings import Settings
from database import client
from models import Organization, OrganizationResponse
import secrets
import string
from fastapi.encoders import jsonable_encoder

router = APIRouter(prefix="/organizations", tags=["Organizations"])
settings = Settings()


def generate_random_string():
return "".join(
[
secrets.choice(string.ascii_letters + string.digits)
for _ in range(settings.random_string_length)
]
)


@router.post("/", response_model=OrganizationResponse)
async def create_organization(organization: Organization):
organization = jsonable_encoder(organization)

# create an API key
key = generate_random_string()

# check if API key exists
if (client.quiz.organization.find_one({"key": key})) is None:
organization["key"] = key
new_organization = client.quiz.organization.insert_one(organization)
created_organization = client.quiz.organization.find_one(
{"_id": new_organization.inserted_id}
)
return created_organization


@router.get("/authenticate/{api_key}", response_model=OrganizationResponse)
async def check_auth_token(api_key: str):

if (
org := client.quiz.organization.find_one(
{"key": api_key},
)
) is not None:
return org

raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"org with key {api_key} not found",
)
8 changes: 2 additions & 6 deletions app/routers/session_answers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi.encoders import jsonable_encoder
from database import client
from models import SessionAnswerResponse, UpdateSessionAnswer
from utils import remove_optional_unset_args

router = APIRouter(prefix="/session_answers", tags=["Session Answers"])

Expand All @@ -11,14 +12,9 @@
async def update_session_answer(
session_answer_id: str, session_answer: UpdateSessionAnswer
):
session_answer = remove_optional_unset_args(session_answer)
session_answer = jsonable_encoder(session_answer)

if "answer" not in session_answer:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No value provided for 'answer'",
)

if (client.quiz.session_answers.find_one({"_id": session_answer_id})) is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
Expand Down
2 changes: 2 additions & 0 deletions app/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ class QuestionType(Enum):
single_choice = "single-choice"
multi_choice = "multi-choice"
subjective = "subjective"
numerical_integer = "numerical-integer"
numerical_float = "numerical-float"


class NavigationMode(Enum):
Expand Down
5 changes: 5 additions & 0 deletions app/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from pydantic import BaseSettings


class Settings(BaseSettings):
random_string_length: int = 20
37 changes: 37 additions & 0 deletions app/tests/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import unittest
import json
from fastapi.testclient import TestClient
from mongoengine import connect, disconnect
from main import app
from routers import quizzes, sessions


class BaseTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
connect("mongoenginetest", host="mongomock://127.0.0.1:8000")
cls.client = TestClient(app)

@classmethod
def tearDownClass(cls):
disconnect()

def setUp(self):
self.quiz_data = json.load(open("app/tests/dummy_data/homework_quiz.json"))
# We are currently not providing an endpoint for creating questions and the only way to
# create a question is through the quiz endpoint which is why we are using the quiz endpoint
# to create questions and a quiz
response = self.client.post(quizzes.router.prefix + "/", json=self.quiz_data)
self.quiz = json.loads(response.content)


class SessionsBaseTestCase(BaseTestCase):
def setUp(self):
super().setUp()

# create a session (and thus, session answers as well) for the dummy quiz that we have created
response = self.client.post(
sessions.router.prefix + "/",
json={"quiz_id": self.quiz["_id"], "user_id": 1},
)
self.session = json.loads(response.content)
File renamed without changes.
32 changes: 6 additions & 26 deletions app/tests/test_questions.py
Original file line number Diff line number Diff line change
@@ -1,39 +1,19 @@
from fastapi.testclient import TestClient
import unittest
import json
from mongoengine import connect, disconnect
from main import app
from .base import BaseTestCase

client = TestClient(app)


class QuestionsTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
connect("mongoenginetest", host="mongomock://127.0.0.1:8000")

@classmethod
def tearDownClass(cls):
disconnect()

class QuestionsTestCase(BaseTestCase):
def setUp(self):
data = open("app/dummy_data/homework_quiz.json")
quiz_data = json.load(data)
# We are currently not providing an endpoint for creating questions and the only way to
# create a question is through the quiz endpoint which is why we are using the quiz endpoint
# to create dummy questions
response = client.post("/quiz/", json=quiz_data)
response = json.loads(response.content)
question = response["question_sets"][0]["questions"][0]
super().setUp()
question = self.quiz["question_sets"][0]["questions"][0]
self.question_id, self.text = question["_id"], question["text"]

def test_get_question_returns_error_if_id_invalid(self):
response = client.get("/questions/00")
response = self.client.get("/questions/00")
assert response.status_code == 404
response = response.json()
assert response["detail"] == "Question 00 not found"

def test_get_question_if_id_valid(self):
response = client.get(f"/questions/{self.question_id}")
response = self.client.get(f"/questions/{self.question_id}")
question = response.json()
assert question["text"] == self.text
33 changes: 9 additions & 24 deletions app/tests/test_quizzes.py
Original file line number Diff line number Diff line change
@@ -1,44 +1,29 @@
from fastapi.testclient import TestClient
import unittest
import json
from mongoengine import connect, disconnect
from main import app
from .base import BaseTestCase
from ..routers import quizzes

client = TestClient(app)


class QuizTestCase(unittest.TestCase):
@classmethod
def setUpClass(cls):
connect("mongoenginetest", host="mongomock://127.0.0.1:8000")

@classmethod
def tearDownClass(cls):
disconnect()

class QuizTestCase(BaseTestCase):
def setUp(self):
data = open("app/dummy_data/homework_quiz.json")
self.quiz_data = json.load(data)
response = client.post("/quiz/", json=self.quiz_data)
response = json.loads(response.content)
self.id = response["_id"]
super().setUp()
self.id = self.quiz["_id"]
self.length = len(self.quiz_data["question_sets"][0]["questions"])

def test_create_quiz(self):
response = client.post("/quiz/", json=self.quiz_data)
response = self.client.post(quizzes.router.prefix + "/", json=self.quiz_data)
response = json.loads(response.content)
id = response["_id"]
response = client.get(f"/quiz/{id}")
response = self.client.get(f"{quizzes.router.prefix}/{id}")
assert response.status_code == 200

def test_get_question_if_id_valid(self):
response = client.get(f"/quiz/{self.id}")
response = self.client.get(f"{quizzes.router.prefix}/{self.id}")
assert response.status_code == 200
response = response.json()
assert len(response["question_sets"][0]["questions"]) == self.length

def test_get_quiz_returns_error_if_id_invalid(self):
response = client.get("/quiz/00")
response = self.client.get(f"{quizzes.router.prefix}/00")
assert response.status_code == 404
response = response.json()
assert response["detail"] == "quiz 00 not found"
Loading

0 comments on commit cbe8c89

Please sign in to comment.