From 41169d155f025c78a68889d36b3cc4ebb07a99cf Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 8 Jul 2024 21:35:32 +0100 Subject: Move existing models to schemas namespace This is to make room for a new ORM namespace for SQLAlchemy models --- backend/authentication/user.py | 5 +- backend/discord.py | 23 ++--- backend/models/__init__.py | 19 ---- backend/models/antispam.py | 9 -- backend/models/discord_role.py | 38 -------- backend/models/discord_user.py | 54 ------------ backend/models/dtos/__init__.py | 19 ++++ backend/models/dtos/antispam.py | 9 ++ backend/models/dtos/discord_role.py | 38 ++++++++ backend/models/dtos/discord_user.py | 54 ++++++++++++ backend/models/dtos/form.py | 164 +++++++++++++++++++++++++++++++++++ backend/models/dtos/form_response.py | 37 ++++++++ backend/models/dtos/question.py | 83 ++++++++++++++++++ backend/models/form.py | 164 ----------------------------------- backend/models/form_response.py | 37 -------- backend/models/orm/__init__.py | 0 backend/models/question.py | 83 ------------------ backend/routes/discord.py | 9 +- backend/routes/forms/condorcet.py | 2 +- backend/routes/forms/discover.py | 2 +- backend/routes/forms/form.py | 2 +- backend/routes/forms/index.py | 4 +- backend/routes/forms/response.py | 2 +- backend/routes/forms/responses.py | 2 +- backend/routes/forms/submit.py | 2 +- backend/routes/forms/unittesting.py | 2 +- docker-compose.yml | 28 +++++- 27 files changed, 457 insertions(+), 434 deletions(-) delete mode 100644 backend/models/antispam.py delete mode 100644 backend/models/discord_role.py delete mode 100644 backend/models/discord_user.py create mode 100644 backend/models/dtos/__init__.py create mode 100644 backend/models/dtos/antispam.py create mode 100644 backend/models/dtos/discord_role.py create mode 100644 backend/models/dtos/discord_user.py create mode 100644 backend/models/dtos/form.py create mode 100644 backend/models/dtos/form_response.py create mode 100644 backend/models/dtos/question.py delete mode 100644 backend/models/form.py delete mode 100644 backend/models/form_response.py create mode 100644 backend/models/orm/__init__.py delete mode 100644 backend/models/question.py diff --git a/backend/authentication/user.py b/backend/authentication/user.py index 5e99546..6ad4c63 100644 --- a/backend/authentication/user.py +++ b/backend/authentication/user.py @@ -4,8 +4,9 @@ import jwt from pymongo.database import Database from starlette.authentication import BaseUser -from backend import discord, models +from backend import discord from backend.constants import SECRET_KEY +from backend.models import dtos class User(BaseUser): @@ -15,7 +16,7 @@ class User(BaseUser): self, token: str, payload: dict[str, t.Any], - member: models.DiscordMember | None, + member: dtos.DiscordMember | None, ) -> None: self.token = token self.payload = payload diff --git a/backend/discord.py b/backend/discord.py index 4a1ecf5..22826e2 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -6,7 +6,8 @@ import httpx import starlette.requests from starlette import exceptions -from backend import constants, models +from backend import constants +from backend.models import dtos async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict: @@ -51,7 +52,7 @@ async def fetch_user_details(bearer_token: str) -> dict: return r.json() -async def _get_role_info() -> list[models.DiscordRole]: +async def _get_role_info() -> list[dtos.DiscordRole]: """Get information about the roles in the configured guild.""" async with httpx.AsyncClient() as client: r = await client.get( @@ -60,13 +61,13 @@ async def _get_role_info() -> list[models.DiscordRole]: ) r.raise_for_status() - return [models.DiscordRole(**role) for role in r.json()] + return [dtos.DiscordRole(**role) for role in r.json()] async def get_roles( *, force_refresh: bool = False, -) -> list[models.DiscordRole]: +) -> list[dtos.DiscordRole]: """ Get a list of all roles from the cache, or discord API if not available. @@ -77,7 +78,7 @@ async def get_roles( roles = await constants.REDIS_CLIENT.hgetall(role_cache_key) if roles: return [ - models.DiscordRole(**json.loads(role_data)) for role_id, role_data in roles.items() + dtos.DiscordRole(**json.loads(role_data)) for role_id, role_data in roles.items() ] roles = await _get_role_info() @@ -86,7 +87,7 @@ async def get_roles( return roles -async def _fetch_member_api(member_id: str) -> models.DiscordMember | None: +async def _fetch_member_api(member_id: str) -> dtos.DiscordMember | None: """Get a member by ID from the configured guild using the discord API.""" async with httpx.AsyncClient() as client: r = await client.get( @@ -99,14 +100,14 @@ async def _fetch_member_api(member_id: str) -> models.DiscordMember | None: return None r.raise_for_status() - return models.DiscordMember(**r.json()) + return dtos.DiscordMember(**r.json()) async def get_member( user_id: str, *, force_refresh: bool = False, -) -> models.DiscordMember | None: +) -> dtos.DiscordMember | None: """ Get a member from the cache, or from the discord API. @@ -118,7 +119,7 @@ async def get_member( if not force_refresh: result = await constants.REDIS_CLIENT.get(member_key) if result: - return models.DiscordMember(**json.loads(result)) + return dtos.DiscordMember(**json.loads(result)) member = await _fetch_member_api(user_id) if member: @@ -150,14 +151,14 @@ async def _verify_access_helper( if "admin" in request.auth.scopes: return - form = models.Form(**form) + form = dtos.Form(**form) for role_id in getattr(form, attribute, None) or []: role = await request.state.db.roles.find_one({"id": role_id}) if not role: continue - role = models.DiscordRole(**json.loads(role["data"])) + role = dtos.DiscordRole(**json.loads(role["data"])) if role.name in request.auth.scopes: return diff --git a/backend/models/__init__.py b/backend/models/__init__.py index 336e28b..e69de29 100644 --- a/backend/models/__init__.py +++ b/backend/models/__init__.py @@ -1,19 +0,0 @@ -from .antispam import AntiSpam -from .discord_role import DiscordRole -from .discord_user import DiscordMember, DiscordUser -from .form import Form, FormList -from .form_response import FormResponse, ResponseList -from .question import CodeQuestion, Question - -__all__ = [ - "AntiSpam", - "CodeQuestion", - "DiscordMember", - "DiscordRole", - "DiscordUser", - "Form", - "FormList", - "FormResponse", - "Question", - "ResponseList", -] diff --git a/backend/models/antispam.py b/backend/models/antispam.py deleted file mode 100644 index b596d4d..0000000 --- a/backend/models/antispam.py +++ /dev/null @@ -1,9 +0,0 @@ -from pydantic import BaseModel - - -class AntiSpam(BaseModel): - """Schema model for form response antispam field.""" - - ip_hash: str - user_agent_hash: str - captcha_pass: bool diff --git a/backend/models/discord_role.py b/backend/models/discord_role.py deleted file mode 100644 index 195f557..0000000 --- a/backend/models/discord_role.py +++ /dev/null @@ -1,38 +0,0 @@ -from pydantic import BaseModel - - -class RoleTags(BaseModel): - """Meta information about a discord role.""" - - bot_id: str | None - integration_id: str | None - premium_subscriber: bool - - def __init__(self, **data) -> None: - """ - Handle the terrible discord API. - - Discord only returns the premium_subscriber field if it's true, - meaning the typical validation process wouldn't work. - - We manually parse the raw data to determine if the field exists, and give it a useful - bool value. - """ - data["premium_subscriber"] = "premium_subscriber" in data - super().__init__(**data) - - -class DiscordRole(BaseModel): - """Schema model of Discord guild roles.""" - - id: str - name: str - color: int - hoist: bool - icon: str | None - unicode_emoji: str | None - position: int - permissions: str - managed: bool - mentionable: bool - tags: RoleTags | None diff --git a/backend/models/discord_user.py b/backend/models/discord_user.py deleted file mode 100644 index be10672..0000000 --- a/backend/models/discord_user.py +++ /dev/null @@ -1,54 +0,0 @@ -import datetime -import typing as t - -from pydantic import BaseModel - - -class _User(BaseModel): - """Base for discord users and members.""" - - # Discord default fields. - username: str - id: str - discriminator: str - avatar: str | None - bot: bool | None - system: bool | None - locale: str | None - verified: bool | None - email: str | None - flags: int | None - premium_type: int | None - public_flags: int | None - - -class DiscordUser(_User): - """Schema model of Discord user for form response.""" - - # Custom fields - admin: bool - - -class DiscordMember(BaseModel): - """A discord guild member.""" - - user: _User - nick: str | None - avatar: str | None - roles: list[str] - joined_at: datetime.datetime - premium_since: datetime.datetime | None - deaf: bool - mute: bool - pending: bool | None - permissions: str | None - communication_disabled_until: datetime.datetime | None - - def dict(self, *args, **kwargs) -> dict[str, t.Any]: - """Convert the model to a python dict, and encode timestamps in a serializable format.""" - data = super().dict(*args, **kwargs) - for field, value in data.items(): - if isinstance(value, datetime.datetime): - data[field] = value.isoformat() - - return data diff --git a/backend/models/dtos/__init__.py b/backend/models/dtos/__init__.py new file mode 100644 index 0000000..336e28b --- /dev/null +++ b/backend/models/dtos/__init__.py @@ -0,0 +1,19 @@ +from .antispam import AntiSpam +from .discord_role import DiscordRole +from .discord_user import DiscordMember, DiscordUser +from .form import Form, FormList +from .form_response import FormResponse, ResponseList +from .question import CodeQuestion, Question + +__all__ = [ + "AntiSpam", + "CodeQuestion", + "DiscordMember", + "DiscordRole", + "DiscordUser", + "Form", + "FormList", + "FormResponse", + "Question", + "ResponseList", +] diff --git a/backend/models/dtos/antispam.py b/backend/models/dtos/antispam.py new file mode 100644 index 0000000..b596d4d --- /dev/null +++ b/backend/models/dtos/antispam.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class AntiSpam(BaseModel): + """Schema model for form response antispam field.""" + + ip_hash: str + user_agent_hash: str + captcha_pass: bool diff --git a/backend/models/dtos/discord_role.py b/backend/models/dtos/discord_role.py new file mode 100644 index 0000000..195f557 --- /dev/null +++ b/backend/models/dtos/discord_role.py @@ -0,0 +1,38 @@ +from pydantic import BaseModel + + +class RoleTags(BaseModel): + """Meta information about a discord role.""" + + bot_id: str | None + integration_id: str | None + premium_subscriber: bool + + def __init__(self, **data) -> None: + """ + Handle the terrible discord API. + + Discord only returns the premium_subscriber field if it's true, + meaning the typical validation process wouldn't work. + + We manually parse the raw data to determine if the field exists, and give it a useful + bool value. + """ + data["premium_subscriber"] = "premium_subscriber" in data + super().__init__(**data) + + +class DiscordRole(BaseModel): + """Schema model of Discord guild roles.""" + + id: str + name: str + color: int + hoist: bool + icon: str | None + unicode_emoji: str | None + position: int + permissions: str + managed: bool + mentionable: bool + tags: RoleTags | None diff --git a/backend/models/dtos/discord_user.py b/backend/models/dtos/discord_user.py new file mode 100644 index 0000000..be10672 --- /dev/null +++ b/backend/models/dtos/discord_user.py @@ -0,0 +1,54 @@ +import datetime +import typing as t + +from pydantic import BaseModel + + +class _User(BaseModel): + """Base for discord users and members.""" + + # Discord default fields. + username: str + id: str + discriminator: str + avatar: str | None + bot: bool | None + system: bool | None + locale: str | None + verified: bool | None + email: str | None + flags: int | None + premium_type: int | None + public_flags: int | None + + +class DiscordUser(_User): + """Schema model of Discord user for form response.""" + + # Custom fields + admin: bool + + +class DiscordMember(BaseModel): + """A discord guild member.""" + + user: _User + nick: str | None + avatar: str | None + roles: list[str] + joined_at: datetime.datetime + premium_since: datetime.datetime | None + deaf: bool + mute: bool + pending: bool | None + permissions: str | None + communication_disabled_until: datetime.datetime | None + + def dict(self, *args, **kwargs) -> dict[str, t.Any]: + """Convert the model to a python dict, and encode timestamps in a serializable format.""" + data = super().dict(*args, **kwargs) + for field, value in data.items(): + if isinstance(value, datetime.datetime): + data[field] = value.isoformat() + + return data diff --git a/backend/models/dtos/form.py b/backend/models/dtos/form.py new file mode 100644 index 0000000..739464e --- /dev/null +++ b/backend/models/dtos/form.py @@ -0,0 +1,164 @@ +import typing as t + +import httpx +from pydantic import BaseModel, Field, constr, root_validator, validator +from pydantic.error_wrappers import ErrorWrapper, ValidationError + +from backend.constants import DISCORD_GUILD, FormFeatures, WebHook + +from .question import Question + +PUBLIC_FIELDS = [ + "id", + "features", + "questions", + "name", + "description", + "submitted_text", + "discord_role", +] + + +class _WebHook(BaseModel): + """Schema model of discord webhooks.""" + + url: str + message: str | None + + @validator("url") + def validate_url(cls, url: str) -> str: + """Validates URL parameter.""" + if "discord.com/api/webhooks/" not in url: + msg = "URL must be a discord webhook." + raise ValueError(msg) + + return url + + +class Form(BaseModel): + """Schema model for form.""" + + id: constr(to_lower=True) = Field(alias="_id") + features: list[str] + questions: list[Question] + name: str + description: str + submitted_text: str | None = None + webhook: _WebHook = None + discord_role: str | None + response_readers: list[str] | None + editors: list[str] | None + + class Config: + allow_population_by_field_name = True + + @validator("features") + def validate_features(cls, value: list[str]) -> list[str]: + """Validates is all features in allowed list.""" + # Uppercase everything to avoid mixed case in DB + value = [v.upper() for v in value] + allowed_values = [v.value for v in FormFeatures.__members__.values()] + if any(v not in allowed_values for v in value): + msg = "Form features list contains one or more invalid values." + raise ValueError(msg) + + if FormFeatures.REQUIRES_LOGIN.value not in value: + if FormFeatures.COLLECT_EMAIL.value in value: + msg = "COLLECT_EMAIL feature require REQUIRES_LOGIN feature." + raise ValueError(msg) + + if FormFeatures.ASSIGN_ROLE.value in value: + msg = "ASSIGN_ROLE feature require REQUIRES_LOGIN feature." + raise ValueError(msg) + + return value + + @validator("response_readers", "editors") + def validate_role_scoping(cls, value: list[str] | None) -> list[str]: + """Ensure special role based permissions aren't granted to the @everyone role.""" + if value and DISCORD_GUILD in value: + msg = "You can not add the everyone role as an access scope." + raise ValueError(msg) + return value + + @root_validator + def validate_role(cls, values: dict[str, t.Any]) -> dict[str, t.Any]: + """Validates does Discord role provided when flag provided.""" + is_role_assigner = FormFeatures.ASSIGN_ROLE.value in values.get("features", []) + if is_role_assigner and not values.get("discord_role"): + msg = "discord_role field is required when ASSIGN_ROLE flag is provided." + raise ValueError(msg) + + return values + + def dict(self, admin: bool = True, **kwargs) -> dict[str, t.Any]: # noqa: FBT001, FBT002 + """Wrapper for original function to exclude private data for public access.""" + data = super().dict(**kwargs) + if admin: + return data + + returned_data = {} + + for field in PUBLIC_FIELDS: + fetch_field = "_id" if field == "id" and kwargs.get("by_alias") else field + returned_data[field] = data[fetch_field] + + # Replace the unittest data section of code questions with the number of test cases. + for question in returned_data["questions"]: + if question["type"] == "code" and question["data"]["unittests"] is not None: + question["data"]["unittests"]["tests"] = len(question["data"]["unittests"]["tests"]) + return returned_data + + +class FormList(BaseModel): + __root__: list[Form] + + +async def validate_hook_url(url: str) -> ValidationError | None: + """Validator for discord webhook urls.""" + + async def validate() -> str | None: + if not isinstance(url, str): + msg = "Webhook URL must be a string." + raise TypeError(msg) + + if "discord.com/api/webhooks/" not in url: + msg = "URL must be a discord webhook." + raise ValueError(msg) + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url) + response.raise_for_status() + + except httpx.RequestError as error: + # Catch exceptions in request format + msg = f"Encountered error while trying to connect to url: `{error}`" + raise ValueError(msg) + + except httpx.HTTPStatusError as error: + # Catch exceptions in response + status = error.response.status_code + + if status == 401: + msg = "Could not authenticate with target. Please check the webhook url." + raise ValueError(msg) + if status == 404: + msg = "Target could not find webhook url. Please check the webhook url." + raise ValueError(msg) + + msg = f"Unknown error ({status}) while connecting to target: {error}" + raise ValueError(msg) + + return url + + # Validate, and return errors, if any + try: + await validate() + except Exception as e: # noqa: BLE001 + loc = ( + WebHook.__name__.lower(), + WebHook.URL.value, + ) + + return ValidationError([ErrorWrapper(e, loc=loc)], _WebHook) diff --git a/backend/models/dtos/form_response.py b/backend/models/dtos/form_response.py new file mode 100644 index 0000000..3c8297b --- /dev/null +++ b/backend/models/dtos/form_response.py @@ -0,0 +1,37 @@ +import datetime +import typing as t + +from pydantic import BaseModel, Field, validator + +from .antispam import AntiSpam +from .discord_user import DiscordUser + + +class FormResponse(BaseModel): + """Schema model for form response.""" + + id: str = Field(alias="_id") + user: DiscordUser | None + antispam: AntiSpam | None + response: dict[str, t.Any] + form_id: str + timestamp: str + + @validator("timestamp", pre=True) + def set_timestamp(cls, iso_string: str | None) -> str: + if iso_string is None: + return datetime.datetime.now(tz=datetime.UTC).isoformat() + + if not isinstance(iso_string, str): + msg = "Submission timestamp must be a string." + raise TypeError(msg) + + # Convert to datetime and back to ensure string is valid + return datetime.datetime.fromisoformat(iso_string).isoformat() + + class Config: + allow_population_by_field_name = True + + +class ResponseList(BaseModel): + __root__: list[FormResponse] diff --git a/backend/models/dtos/question.py b/backend/models/dtos/question.py new file mode 100644 index 0000000..a13ce93 --- /dev/null +++ b/backend/models/dtos/question.py @@ -0,0 +1,83 @@ +import typing as t + +from pydantic import BaseModel, Field, root_validator, validator + +from backend.constants import QUESTION_TYPES, REQUIRED_QUESTION_TYPE_DATA + +_TESTS_TYPE = dict[str, str] | int + + +class Unittests(BaseModel): + """Schema model for unittest suites in code questions.""" + + allow_failure: bool = False + tests: _TESTS_TYPE + + @validator("tests") + def validate_tests(cls, value: _TESTS_TYPE) -> _TESTS_TYPE: + """Confirm that at least one test exists in a test suite.""" + if isinstance(value, dict): + keys = len(value.keys()) - (1 if "setUp" in value else 0) + if keys == 0: + msg = "Must have at least one test in a test suite." + raise ValueError(msg) + + return value + + +class CodeQuestion(BaseModel): + """Schema model for questions of type `code`.""" + + language: str + unittests: Unittests | None + + +class Question(BaseModel): + """Schema model for form question.""" + + id: str = Field(alias="_id") + name: str + type: str + data: dict[str, t.Any] + required: bool + + class Config: + allow_population_by_field_name = True + + @validator("type", pre=True) + def validate_question_type(cls, value: str) -> str: + """Checks if question type in currently allowed types list.""" + value = value.lower() + if value not in QUESTION_TYPES: + msg = f"{value} is not valid question type. Allowed question types: {QUESTION_TYPES}." + raise ValueError(msg) + + return value + + @root_validator + def validate_question_data( + cls, + value: dict[str, t.Any], + ) -> 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 value.get("type") not in REQUIRED_QUESTION_TYPE_DATA: + return value + + for key, data_type in REQUIRED_QUESTION_TYPE_DATA[value["type"]].items(): + if key not in value.get("data", {}): + msg = f"Required question data key '{key}' not provided." + raise ValueError(msg) + + if not isinstance(value["data"][key], data_type): + msg = ( + f"Question data key '{key}' expects {data_type.__name__}, " + f"got {type(value["data"][key]).__name__} instead." + ) + raise TypeError(msg) + + # Validate unittest options + if value.get("type").lower() == "code": + value["data"] = CodeQuestion(**value.get("data")).dict() + + return value diff --git a/backend/models/form.py b/backend/models/form.py deleted file mode 100644 index 739464e..0000000 --- a/backend/models/form.py +++ /dev/null @@ -1,164 +0,0 @@ -import typing as t - -import httpx -from pydantic import BaseModel, Field, constr, root_validator, validator -from pydantic.error_wrappers import ErrorWrapper, ValidationError - -from backend.constants import DISCORD_GUILD, FormFeatures, WebHook - -from .question import Question - -PUBLIC_FIELDS = [ - "id", - "features", - "questions", - "name", - "description", - "submitted_text", - "discord_role", -] - - -class _WebHook(BaseModel): - """Schema model of discord webhooks.""" - - url: str - message: str | None - - @validator("url") - def validate_url(cls, url: str) -> str: - """Validates URL parameter.""" - if "discord.com/api/webhooks/" not in url: - msg = "URL must be a discord webhook." - raise ValueError(msg) - - return url - - -class Form(BaseModel): - """Schema model for form.""" - - id: constr(to_lower=True) = Field(alias="_id") - features: list[str] - questions: list[Question] - name: str - description: str - submitted_text: str | None = None - webhook: _WebHook = None - discord_role: str | None - response_readers: list[str] | None - editors: list[str] | None - - class Config: - allow_population_by_field_name = True - - @validator("features") - def validate_features(cls, value: list[str]) -> list[str]: - """Validates is all features in allowed list.""" - # Uppercase everything to avoid mixed case in DB - value = [v.upper() for v in value] - allowed_values = [v.value for v in FormFeatures.__members__.values()] - if any(v not in allowed_values for v in value): - msg = "Form features list contains one or more invalid values." - raise ValueError(msg) - - if FormFeatures.REQUIRES_LOGIN.value not in value: - if FormFeatures.COLLECT_EMAIL.value in value: - msg = "COLLECT_EMAIL feature require REQUIRES_LOGIN feature." - raise ValueError(msg) - - if FormFeatures.ASSIGN_ROLE.value in value: - msg = "ASSIGN_ROLE feature require REQUIRES_LOGIN feature." - raise ValueError(msg) - - return value - - @validator("response_readers", "editors") - def validate_role_scoping(cls, value: list[str] | None) -> list[str]: - """Ensure special role based permissions aren't granted to the @everyone role.""" - if value and DISCORD_GUILD in value: - msg = "You can not add the everyone role as an access scope." - raise ValueError(msg) - return value - - @root_validator - def validate_role(cls, values: dict[str, t.Any]) -> dict[str, t.Any]: - """Validates does Discord role provided when flag provided.""" - is_role_assigner = FormFeatures.ASSIGN_ROLE.value in values.get("features", []) - if is_role_assigner and not values.get("discord_role"): - msg = "discord_role field is required when ASSIGN_ROLE flag is provided." - raise ValueError(msg) - - return values - - def dict(self, admin: bool = True, **kwargs) -> dict[str, t.Any]: # noqa: FBT001, FBT002 - """Wrapper for original function to exclude private data for public access.""" - data = super().dict(**kwargs) - if admin: - return data - - returned_data = {} - - for field in PUBLIC_FIELDS: - fetch_field = "_id" if field == "id" and kwargs.get("by_alias") else field - returned_data[field] = data[fetch_field] - - # Replace the unittest data section of code questions with the number of test cases. - for question in returned_data["questions"]: - if question["type"] == "code" and question["data"]["unittests"] is not None: - question["data"]["unittests"]["tests"] = len(question["data"]["unittests"]["tests"]) - return returned_data - - -class FormList(BaseModel): - __root__: list[Form] - - -async def validate_hook_url(url: str) -> ValidationError | None: - """Validator for discord webhook urls.""" - - async def validate() -> str | None: - if not isinstance(url, str): - msg = "Webhook URL must be a string." - raise TypeError(msg) - - if "discord.com/api/webhooks/" not in url: - msg = "URL must be a discord webhook." - raise ValueError(msg) - - try: - async with httpx.AsyncClient() as client: - response = await client.get(url) - response.raise_for_status() - - except httpx.RequestError as error: - # Catch exceptions in request format - msg = f"Encountered error while trying to connect to url: `{error}`" - raise ValueError(msg) - - except httpx.HTTPStatusError as error: - # Catch exceptions in response - status = error.response.status_code - - if status == 401: - msg = "Could not authenticate with target. Please check the webhook url." - raise ValueError(msg) - if status == 404: - msg = "Target could not find webhook url. Please check the webhook url." - raise ValueError(msg) - - msg = f"Unknown error ({status}) while connecting to target: {error}" - raise ValueError(msg) - - return url - - # Validate, and return errors, if any - try: - await validate() - except Exception as e: # noqa: BLE001 - loc = ( - WebHook.__name__.lower(), - WebHook.URL.value, - ) - - return ValidationError([ErrorWrapper(e, loc=loc)], _WebHook) diff --git a/backend/models/form_response.py b/backend/models/form_response.py deleted file mode 100644 index 3c8297b..0000000 --- a/backend/models/form_response.py +++ /dev/null @@ -1,37 +0,0 @@ -import datetime -import typing as t - -from pydantic import BaseModel, Field, validator - -from .antispam import AntiSpam -from .discord_user import DiscordUser - - -class FormResponse(BaseModel): - """Schema model for form response.""" - - id: str = Field(alias="_id") - user: DiscordUser | None - antispam: AntiSpam | None - response: dict[str, t.Any] - form_id: str - timestamp: str - - @validator("timestamp", pre=True) - def set_timestamp(cls, iso_string: str | None) -> str: - if iso_string is None: - return datetime.datetime.now(tz=datetime.UTC).isoformat() - - if not isinstance(iso_string, str): - msg = "Submission timestamp must be a string." - raise TypeError(msg) - - # Convert to datetime and back to ensure string is valid - return datetime.datetime.fromisoformat(iso_string).isoformat() - - class Config: - allow_population_by_field_name = True - - -class ResponseList(BaseModel): - __root__: list[FormResponse] diff --git a/backend/models/orm/__init__.py b/backend/models/orm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/models/question.py b/backend/models/question.py deleted file mode 100644 index a13ce93..0000000 --- a/backend/models/question.py +++ /dev/null @@ -1,83 +0,0 @@ -import typing as t - -from pydantic import BaseModel, Field, root_validator, validator - -from backend.constants import QUESTION_TYPES, REQUIRED_QUESTION_TYPE_DATA - -_TESTS_TYPE = dict[str, str] | int - - -class Unittests(BaseModel): - """Schema model for unittest suites in code questions.""" - - allow_failure: bool = False - tests: _TESTS_TYPE - - @validator("tests") - def validate_tests(cls, value: _TESTS_TYPE) -> _TESTS_TYPE: - """Confirm that at least one test exists in a test suite.""" - if isinstance(value, dict): - keys = len(value.keys()) - (1 if "setUp" in value else 0) - if keys == 0: - msg = "Must have at least one test in a test suite." - raise ValueError(msg) - - return value - - -class CodeQuestion(BaseModel): - """Schema model for questions of type `code`.""" - - language: str - unittests: Unittests | None - - -class Question(BaseModel): - """Schema model for form question.""" - - id: str = Field(alias="_id") - name: str - type: str - data: dict[str, t.Any] - required: bool - - class Config: - allow_population_by_field_name = True - - @validator("type", pre=True) - def validate_question_type(cls, value: str) -> str: - """Checks if question type in currently allowed types list.""" - value = value.lower() - if value not in QUESTION_TYPES: - msg = f"{value} is not valid question type. Allowed question types: {QUESTION_TYPES}." - raise ValueError(msg) - - return value - - @root_validator - def validate_question_data( - cls, - value: dict[str, t.Any], - ) -> 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 value.get("type") not in REQUIRED_QUESTION_TYPE_DATA: - return value - - for key, data_type in REQUIRED_QUESTION_TYPE_DATA[value["type"]].items(): - if key not in value.get("data", {}): - msg = f"Required question data key '{key}' not provided." - raise ValueError(msg) - - if not isinstance(value["data"][key], data_type): - msg = ( - f"Question data key '{key}' expects {data_type.__name__}, " - f"got {type(value["data"][key]).__name__} instead." - ) - raise TypeError(msg) - - # Validate unittest options - if value.get("type").lower() == "code": - value["data"] = CodeQuestion(**value.get("data")).dict() - - return value diff --git a/backend/routes/discord.py b/backend/routes/discord.py index 5cd6b47..1a56aac 100644 --- a/backend/routes/discord.py +++ b/backend/routes/discord.py @@ -6,7 +6,8 @@ from starlette.authentication import requires from starlette.responses import JSONResponse from starlette.routing import Request -from backend import discord, models, route +from backend import discord, route +from backend.models import dtos from backend.validation import ErrorMessage, api NOT_FOUND_EXCEPTION = JSONResponse( @@ -24,7 +25,7 @@ class RolesRoute(route.Route): class RolesResponse(pydantic.BaseModel): """A list of all roles on the configured server.""" - roles: list[models.DiscordRole] + roles: list[dtos.DiscordRole] @requires(["authenticated", "admin"]) @api.validate( @@ -53,7 +54,7 @@ class MemberRoute(route.Route): @requires(["authenticated", "admin"]) @api.validate( - resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), + resp=Response(HTTP_200=dtos.DiscordMember, HTTP_400=ErrorMessage), json=MemberRequest, tags=["auth"], ) @@ -68,7 +69,7 @@ class MemberRoute(route.Route): @requires(["authenticated", "admin"]) @api.validate( - resp=Response(HTTP_200=models.DiscordMember, HTTP_400=ErrorMessage), + resp=Response(HTTP_200=dtos.DiscordMember, HTTP_400=ErrorMessage), json=MemberRequest, tags=["auth"], ) diff --git a/backend/routes/forms/condorcet.py b/backend/routes/forms/condorcet.py index 902770b..ac7e52e 100644 --- a/backend/routes/forms/condorcet.py +++ b/backend/routes/forms/condorcet.py @@ -9,7 +9,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend import discord -from backend.models import Form, FormResponse, Question +from backend.models.dtos import Form, FormResponse, Question from backend.route import Route from backend.validation import api diff --git a/backend/routes/forms/discover.py b/backend/routes/forms/discover.py index 0fe10b5..43e6cf3 100644 --- a/backend/routes/forms/discover.py +++ b/backend/routes/forms/discover.py @@ -5,7 +5,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend import constants -from backend.models import Form, FormList, Question +from backend.models.dtos import Form, FormList, Question from backend.route import Route from backend.validation import api diff --git a/backend/routes/forms/form.py b/backend/routes/forms/form.py index 86bbf49..c96d0d6 100644 --- a/backend/routes/forms/form.py +++ b/backend/routes/forms/form.py @@ -10,7 +10,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend import constants, discord -from backend.models import Form +from backend.models.dtos import Form from backend.route import Route from backend.routes.forms.discover import AUTH_FORM from backend.validation import ErrorMessage, OkayResponse, api diff --git a/backend/routes/forms/index.py b/backend/routes/forms/index.py index 1fdfc48..4b55af2 100644 --- a/backend/routes/forms/index.py +++ b/backend/routes/forms/index.py @@ -6,8 +6,8 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend.constants import WebHook -from backend.models import Form, FormList -from backend.models.form import validate_hook_url +from backend.models.dtos import Form, FormList +from backend.models.dtos.form import validate_hook_url from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api diff --git a/backend/routes/forms/response.py b/backend/routes/forms/response.py index b4f7f04..ac80b74 100644 --- a/backend/routes/forms/response.py +++ b/backend/routes/forms/response.py @@ -6,7 +6,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend import discord -from backend.models import FormResponse +from backend.models.dtos import FormResponse from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api diff --git a/backend/routes/forms/responses.py b/backend/routes/forms/responses.py index 85e5af2..4228af8 100644 --- a/backend/routes/forms/responses.py +++ b/backend/routes/forms/responses.py @@ -7,7 +7,7 @@ from starlette.requests import Request from starlette.responses import JSONResponse from backend import discord -from backend.models import FormResponse, ResponseList +from backend.models.dtos import FormResponse, ResponseList from backend.route import Route from backend.validation import ErrorMessage, OkayResponse, api diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 01c32cc..45636b7 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -19,7 +19,7 @@ from starlette.responses import JSONResponse from backend import constants from backend.authentication.user import User -from backend.models import Form, FormResponse +from backend.models.dtos import Form, FormResponse from backend.route import Route from backend.routes.auth.authorize import set_response_token from backend.routes.forms.discover import AUTH_FORM diff --git a/backend/routes/forms/unittesting.py b/backend/routes/forms/unittesting.py index 57c3a86..469243c 100644 --- a/backend/routes/forms/unittesting.py +++ b/backend/routes/forms/unittesting.py @@ -8,7 +8,7 @@ import httpx from httpx import HTTPStatusError from backend.constants import SNEKBOX_URL -from backend.models import Form, FormResponse +from backend.models.dtos import Form, FormResponse with Path("resources/unittest_template.py").open(encoding="utf8") as file: TEST_TEMPLATE = file.read() diff --git a/docker-compose.yml b/docker-compose.yml index a9363f8..b8d58da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,22 +20,42 @@ services: ports: - "127.0.0.1:6379:6379" + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: backend + POSTGRES_PASSWORD: backend + POSTGRES_USER: backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U backend"] + interval: 2s + timeout: 1s + retries: 5 + ports: + - 5000:5432 + backend: build: . command: ["uvicorn", "--reload", "--host", "0.0.0.0", "backend:app"] ports: - "127.0.0.1:8000:8000" depends_on: - - mongo - - snekbox - - redis + mongo: + condition: service_started + snekbox: + condition: service_started + redis: + condition: service_started + postgres: + condition: service_healthy tty: true env_file: - .env volumes: - .:/app:ro environment: - - DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017 + - MONGO_DATABASE_URL=mongodb://forms-backend:forms-backend@mongo:27017 + - PSQL_DATABASE_URL=postgresql+psycopg_async://backend:backend@postgres:5432/backend - SNEKBOX_URL=http://snekbox:8060/eval - OAUTH2_CLIENT_ID - OAUTH2_CLIENT_SECRET -- cgit v1.2.3