diff options
Diffstat (limited to 'backend')
-rw-r--r-- | backend/constants.py | 5 | ||||
-rw-r--r-- | backend/discord.py | 8 | ||||
-rw-r--r-- | backend/models/form.py | 23 | ||||
-rw-r--r-- | backend/routes/forms/submit.py | 49 |
4 files changed, 72 insertions, 13 deletions
diff --git a/backend/constants.py b/backend/constants.py index d90fd9a..7ea4519 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -28,6 +28,8 @@ FORMS_BACKEND_DSN = os.getenv("FORMS_BACKEND_DSN") DOCS_PASSWORD = os.getenv("DOCS_PASSWORD") SECRET_KEY = os.getenv("SECRET_KEY", binascii.hexlify(os.urandom(30)).decode()) +DISCORD_BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN") +DISCORD_GUILD = os.getenv("DISCORD_GUILD", 267624335836053506) HCAPTCHA_API_SECRET = os.getenv("HCAPTCHA_API_SECRET") @@ -60,6 +62,8 @@ REQUIRED_QUESTION_TYPE_DATA = { }, } +DISCORD_API_BASE_URL = "https://discord.com/api/v8" + class FormFeatures(Enum): """Lists form features. Read more in SCHEMA.md.""" @@ -70,6 +74,7 @@ class FormFeatures(Enum): COLLECT_EMAIL = "COLLECT_EMAIL" DISABLE_ANTISPAM = "DISABLE_ANTISPAM" WEBHOOK_ENABLED = "WEBHOOK_ENABLED" + ASSIGN_ROLE = "ASSIGN_ROLE" class WebHook(Enum): diff --git a/backend/discord.py b/backend/discord.py index 8cb602c..e5c7f8f 100644 --- a/backend/discord.py +++ b/backend/discord.py @@ -2,11 +2,9 @@ import httpx from backend.constants import ( - OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET + DISCORD_API_BASE_URL, OAUTH2_CLIENT_ID, OAUTH2_CLIENT_SECRET ) -API_BASE_URL = "https://discord.com/api/v8" - async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict: async with httpx.AsyncClient() as client: @@ -23,7 +21,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict data["grant_type"] = "authorization_code" data["code"] = code - r = await client.post(f"{API_BASE_URL}/oauth2/token", headers={ + r = await client.post(f"{DISCORD_API_BASE_URL}/oauth2/token", headers={ "Content-Type": "application/x-www-form-urlencoded" }, data=data) @@ -34,7 +32,7 @@ async def fetch_bearer_token(code: str, redirect: str, *, refresh: bool) -> dict async def fetch_user_details(bearer_token: str) -> dict: async with httpx.AsyncClient() as client: - r = await client.get(f"{API_BASE_URL}/users/@me", headers={ + r = await client.get(f"{DISCORD_API_BASE_URL}/users/@me", headers={ "Authorization": f"Bearer {bearer_token}" }) diff --git a/backend/models/form.py b/backend/models/form.py index eac0b63..7e1a673 100644 --- a/backend/models/form.py +++ b/backend/models/form.py @@ -1,7 +1,7 @@ import typing as t import httpx -from pydantic import BaseModel, Field, validator +from pydantic import BaseModel, Field, root_validator, validator from pydantic.error_wrappers import ErrorWrapper, ValidationError from backend.constants import FormFeatures, WebHook @@ -34,6 +34,7 @@ class Form(BaseModel): description: str submitted_text: t.Optional[str] = None webhook: _WebHook = None + discord_role: t.Optional[str] class Config: allow_population_by_field_name = True @@ -47,11 +48,27 @@ class Form(BaseModel): if any(v not 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: - raise ValueError("COLLECT_EMAIL feature require REQUIRES_LOGIN feature.") + if FormFeatures.REQUIRES_LOGIN not in value: + if FormFeatures.COLLECT_EMAIL in value: + raise ValueError( + "COLLECT_EMAIL feature require REQUIRES_LOGIN feature." + ) + + if FormFeatures.ASSIGN_ROLE in value: + raise ValueError("ASSIGN_ROLE feature require REQUIRES_LOGIN feature.") return value + @root_validator + def validate_role(cls, values: dict[str, t.Any]) -> t.Optional[dict[str, t.Any]]: + """Validates does Discord role provided when flag provided.""" + if FormFeatures.ASSIGN_ROLE.value in values.get("features", []) and not values.get("discord_role"): # noqa + raise ValueError( + "discord_role field is required when ASSIGN_ROLE flag is provided." + ) + + return values + def dict(self, admin: bool = True, **kwargs: t.Any) -> dict[str, t.Any]: """Wrapper for original function to exclude private data for public access.""" data = super().dict(**kwargs) diff --git a/backend/routes/forms/submit.py b/backend/routes/forms/submit.py index 2624c98..23444a0 100644 --- a/backend/routes/forms/submit.py +++ b/backend/routes/forms/submit.py @@ -2,6 +2,7 @@ Submit a form. """ +import asyncio import binascii import datetime import hashlib @@ -12,7 +13,7 @@ import httpx from pydantic import ValidationError from pydantic.main import BaseModel from spectree import Response -from starlette.background import BackgroundTask +from starlette.background import BackgroundTasks from starlette.requests import Request from starlette.responses import JSONResponse @@ -29,6 +30,10 @@ HCAPTCHA_HEADERS = { "Content-Type": "application/x-www-form-urlencoded" } +DISCORD_HEADERS = { + "Authorization": f"Bot {constants.DISCORD_BOT_TOKEN}" +} + class SubmissionResponse(BaseModel): form: Form @@ -180,19 +185,26 @@ class SubmitForm(Route): response_obj.dict(by_alias=True) ) - send_webhook = None + tasks = BackgroundTasks() if constants.FormFeatures.WEBHOOK_ENABLED.value in form.features: - send_webhook = BackgroundTask( + tasks.add_task( self.send_submission_webhook, form=form, response=response_obj, request_user=request.user ) + if constants.FormFeatures.ASSIGN_ROLE.value in form.features: + tasks.add_task( + self.assign_role, + form=form, + request_user=request.user + ) + return JSONResponse({ "form": form.dict(admin=False), "response": response_obj.dict() - }, background=send_webhook) + }, background=tasks) else: return JSONResponse({ @@ -203,7 +215,7 @@ class SubmitForm(Route): async def send_submission_webhook( form: Form, response: FormResponse, - request_user: Request.user + request_user: User ) -> None: """Helper to send a submission message to a discord webhook.""" # Stop if webhook is not available @@ -262,3 +274,30 @@ class SubmitForm(Route): async with httpx.AsyncClient() as client: r = await client.post(form.webhook.url, json=hook) r.raise_for_status() + + @staticmethod + async def assign_role(form: Form, request_user: User) -> None: + """Assigns Discord role to user when user submitted response.""" + if not form.discord_role: + raise ValueError("Got empty Discord role ID.") + + url = ( + f"{constants.DISCORD_API_BASE_URL}/guilds/{constants.DISCORD_GUILD}" + f"/members/{request_user.payload['id']}/roles/{form.discord_role}" + ) + + async with httpx.AsyncClient() as client: + resp = await client.put(url, headers=DISCORD_HEADERS) + if resp.status_code == 429: # We are rate limited + status = resp.status_code + retry_after = float(resp.headers["X-Ratelimit-Reset-After"]) + while status == 429: + await asyncio.sleep(retry_after) + r = await client.put(url, headers=DISCORD_HEADERS) + status = r.status_code + if status == 429: + retry_after = float(r.headers["X-Ratelimit-Reset-After"]) + else: + r.raise_for_status() + else: # For any other unexpected status, raise error. + resp.raise_for_status() |