Skip to content
Merged
4 changes: 2 additions & 2 deletions backend/middleware.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import typing as t

import pymongo
import ssl
from motor.motor_asyncio import AsyncIOMotorClient
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
Expand All @@ -11,7 +11,7 @@

class DatabaseMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: t.Callable) -> Response:
client = pymongo.MongoClient(
client: AsyncIOMotorClient = AsyncIOMotorClient(
DATABASE_URL,
ssl_cert_reqs=ssl.CERT_NONE
)
Expand Down
10 changes: 7 additions & 3 deletions backend/models/form.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from pydantic import BaseModel, Field, validator

from backend.constants import FormFeatures
from backend.models import Question
from .question import Question


class Form(BaseModel):
Expand All @@ -13,12 +13,16 @@ class Form(BaseModel):
features: t.List[str]
questions: t.List[Question]

class Config:
allow_population_by_field_name = True

@validator("features")
def validate_features(self, value: t.List[str]) -> t.Optional[t.List[str]]:
def validate_features(cls, value: t.List[str]) -> t.Optional[t.List[str]]:
"""Validates is all features in allowed list."""
# Uppercase everything to avoid mixed case in DB
value = [v.upper() for v in value]
if not all(v in FormFeatures.__members__.values() for v in value):
allowed_values = list(v.value for v in FormFeatures.__members__.values())
if not all(v in allowed_values for v in value):
raise ValueError("Form features list contains one or more invalid values.")

if FormFeatures.COLLECT_EMAIL in value and FormFeatures.REQUIRES_LOGIN not in value: # noqa
Expand Down
31 changes: 14 additions & 17 deletions backend/models/question.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import typing as t

from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field, root_validator, validator

from backend.constants import QUESTION_TYPES, REQUIRED_QUESTION_TYPE_DATA

Expand All @@ -13,8 +13,11 @@ class Question(BaseModel):
type: str
data: t.Dict[str, t.Any]

class Config:
allow_population_by_field_name = True

@validator("type", pre=True)
def validate_question_type(self, value: str) -> t.Optional[str]:
def validate_question_type(cls, value: str) -> t.Optional[str]:
"""Checks if question type in currently allowed types list."""
value = value.lower()
if value not in QUESTION_TYPES:
Expand All @@ -25,30 +28,24 @@ def validate_question_type(self, value: str) -> t.Optional[str]:

return value

@validator("data")
@root_validator
def validate_question_data(
self,
cls,
value: t.Dict[str, t.Any]
) -> t.Optional[t.Dict[str, t.Any]]:
"""Check does required data exists for question type and remove other data."""
# When question type don't need data, don't add anything to keep DB clean.
if self.type not in REQUIRED_QUESTION_TYPE_DATA:
return {}

# Required keys (and values) will be stored to here
# to remove all unnecessary stuff
result = {}
if value.get("type") not in REQUIRED_QUESTION_TYPE_DATA:
return value

for key, data_type in REQUIRED_QUESTION_TYPE_DATA[self.type].items():
if key not in value:
for key, data_type in REQUIRED_QUESTION_TYPE_DATA[value.get("type")].items():
if key not in value.get("data", {}):
raise ValueError(f"Required question data key '{key}' not provided.")

if not isinstance(value[key], data_type):
if not isinstance(value["data"][key], data_type):
raise ValueError(
f"Question data key '{key}' expects {data_type.__name__}, "
f"got {type(value[key]).__name__} instead."
f"got {type(value['data'][key]).__name__} instead."
)

result[key] = value[key]

return result
return value
2 changes: 1 addition & 1 deletion backend/routes/auth/authorize.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ async def post(self, request: Request) -> JSONResponse:
bearer_token = await fetch_bearer_token(data["token"])
user_details = await fetch_user_details(bearer_token["access_token"])

user_details["admin"] = request.state.db.admins.find_one(
user_details["admin"] = await request.state.db.admins.find_one(
{"_id": user_details["id"]}
) is not None

Expand Down
12 changes: 8 additions & 4 deletions backend/routes/forms/discover.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from starlette.requests import Request
from starlette.responses import JSONResponse

from backend.models import Form
from backend.route import Route


Expand All @@ -17,11 +18,14 @@ class DiscoverableFormsList(Route):

async def get(self, request: Request) -> JSONResponse:
forms = []
cursor = request.state.db.forms.find({"features": "DISCOVERABLE"})

for form in request.state.db.forms.find({
"features": "DISCOVERABLE"
}):
forms.append(form)
# Parse it to Form and then back to dictionary
# to replace _id with id
for form in await cursor.to_list(None):
forms.append(Form(**form))

forms = [form.dict() for form in forms]

return JSONResponse(
forms
Expand Down
11 changes: 9 additions & 2 deletions backend/routes/forms/index.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""
Return a list of all forms to authenticated users.
"""
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import JSONResponse

from backend.route import Route
from backend.models import Form


class FormsList(Route):
Expand All @@ -15,11 +17,16 @@ class FormsList(Route):
name = "forms_list"
path = "/"

@requires(["authenticated", "admin"])
async def get(self, request: Request) -> JSONResponse:
forms = []
cursor = request.state.db.forms.find()

for form in request.state.db.forms.find():
forms.append(form)
for form in await cursor.to_list(None):
forms.append(Form(**form)) # For converting _id to id

# Covert them back to dictionaries
forms = [form.dict() for form in forms]

return JSONResponse(
forms
Expand Down
30 changes: 30 additions & 0 deletions backend/routes/forms/new.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""
Creates new form based on data provided.
"""
from pydantic import ValidationError
from starlette.authentication import requires
from starlette.requests import Request
from starlette.responses import JSONResponse

from backend.models import Form
from backend.route import Route


class FormCreate(Route):
"""
Creates new form from JSON data.
"""

name = "forms_create"
path = "/new"

@requires(["authenticated", "admin"])
async def post(self, request: Request) -> JSONResponse:
form_data = await request.json()
try:
form = Form(**form_data)
except ValidationError as e:
return JSONResponse(e.errors())

await request.state.db.forms.insert_one(form.dict(by_alias=True))
return JSONResponse(form.dict())
2 changes: 1 addition & 1 deletion backend/routes/forms/submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class SubmitForm(Route):
async def post(self, request: Request) -> JSONResponse:
data = await request.json()

if form := request.state.db.forms.find_one(
if form := await request.state.db.forms.find_one(
{"_id": request.path_params["form_id"], "features": "OPEN"}
):
response_obj = {}
Expand Down
63 changes: 39 additions & 24 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ python = "^3.9"
starlette = "^0.13.8"
nested_dict = "^1.61"
uvicorn = {extras = ["standard"], version = "^0.12.2"}
pymongo = "^3.11.0"
motor = "^2.3.0"
python-dotenv = "^0.14.0"
pyjwt = "^1.7.1"
httpx = "^0.16.1"
Expand Down