Skip to content

Commit aad5ba7

Browse files
committed
feat(resource): add captcha on create contact mutation
1 parent 27225c0 commit aad5ba7

File tree

6 files changed

+80
-26
lines changed

6 files changed

+80
-26
lines changed

apps/resources/graphql/inputs.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import strawberry
2-
import strawberry_django
32

4-
from apps.resources.models import ContactRequest
53

6-
7-
@strawberry_django.input(ContactRequest)
4+
@strawberry.input
85
class ContactRequestInput:
9-
name: strawberry.auto
10-
email: strawberry.auto
11-
national_society: strawberry.auto
12-
content: strawberry.auto
6+
name: str
7+
email: str
8+
national_society: str
9+
content: str
10+
captcha_hashkey: str
11+
captcha_code: str
Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,42 @@
11
import strawberry
22
import strawberry_django
3-
from strawberry_django.permissions import IsAuthenticated
3+
from asgiref.sync import sync_to_async
4+
from rest_framework.exceptions import ValidationError
45

56
from apps.resources.graphql.inputs import ContactRequestInput
67
from apps.resources.graphql.types import ContactRequestType
78
from apps.resources.serializers import ContactRequestSerializer
89
from main.graphql.context import Info
10+
from utils.common import validate_captcha
911
from utils.graphql.mutations import ModelMutation
10-
from utils.graphql.types import MutationResponseType
12+
from utils.graphql.types import CustomErrorType, MutationResponseType
1113

1214

1315
@strawberry.type
1416
class Mutation:
15-
@strawberry_django.mutation(extensions=[IsAuthenticated()])
17+
@strawberry_django.mutation()
1618
async def create_contact_request(
1719
self,
1820
info: Info,
1921
data: ContactRequestInput,
2022
) -> MutationResponseType[ContactRequestType]:
21-
return await ModelMutation(ContactRequestSerializer).handle_create_mutation(data, info, None)
23+
try:
24+
await sync_to_async(validate_captcha)(data.captcha_hashkey, data.captcha_code)
25+
except ValidationError as e:
26+
return MutationResponseType(
27+
ok=False,
28+
errors=CustomErrorType(
29+
{
30+
"code": None,
31+
"field": "captch",
32+
"kind": "VALIDATION",
33+
"messages": str(e),
34+
},
35+
),
36+
result=None,
37+
)
38+
return await ModelMutation(ContactRequestSerializer).handle_create_mutation(
39+
data,
40+
info,
41+
None,
42+
)

apps/resources/tests/mutation_test.py

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import typing
22

3+
from captcha.models import CaptchaStore
4+
35
from apps.resources.models import ContactRequest
46
from apps.user.factories import UserFactory
57
from main.tests import TestCase
@@ -49,27 +51,20 @@ def _create_contact_request_mutation(self, data: dict[str, str], **kwargs: typin
4951
)
5052

5153
def test_create_contact_request(self):
54+
# Generates CAPTCHA key and value
55+
captcha_key = CaptchaStore.generate_key()
56+
captcha_obj = CaptchaStore.objects.get(hashkey=captcha_key)
57+
captcha_code = captcha_obj.response
58+
5259
contact_request_data = {
5360
"name": "John",
5461
"email": "john@test.com",
5562
"nationalSociety": "Test National Society",
5663
"content": "This is test content",
64+
"captchaHashkey": captcha_key,
65+
"captchaCode": captcha_code,
5766
}
5867

59-
# Without authentication
60-
content = self._create_contact_request_mutation(contact_request_data)
61-
62-
assert content["data"]["createContactRequest"]["messages"] == [
63-
{
64-
"code": None,
65-
"field": "createContactRequest",
66-
"kind": "PERMISSION",
67-
"message": "User is not authenticated.",
68-
},
69-
]
70-
71-
# With authentication
72-
self.force_login(self.user)
7368
content = self._create_contact_request_mutation(data=contact_request_data)
7469
response_data = content["data"]["createContactRequest"]
7570

@@ -84,3 +79,23 @@ def test_create_contact_request(self):
8479
"content": contact_request.content,
8580
"nationalSociety": contact_request.national_society,
8681
}
82+
83+
def test_create_contact_request_without_captcha(self):
84+
contact_request_data = {
85+
"name": "John",
86+
"email": "john@test.com",
87+
"nationalSociety": "Test National Society",
88+
"content": "This is test content",
89+
"captchaHashkey": "",
90+
"captchaCode": "",
91+
}
92+
93+
content = self._create_contact_request_mutation(data=contact_request_data)
94+
assert content["data"]["createContactRequest"]["messages"] == [
95+
{
96+
"code": None,
97+
"field": None,
98+
"kind": "VALIDATION",
99+
"message": "CAPTCHA is required.",
100+
},
101+
], content

main/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@
105105
"djangoql",
106106
"rest_framework",
107107
"mdeditor",
108+
"captcha",
108109
# - Health-check
109110
"health_check", # required
110111
"health_check.db",

main/urls.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
path("admin/", admin.site.urls),
1717
path("health-check/", include("health_check.urls")),
1818
path(r"mdeditor/", include("mdeditor.urls")),
19+
path("captcha/", include("captcha.urls")),
1920
path(
2021
"graphql/",
2122
csrf_exempt(

utils/common.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import string
99
import typing
1010

11+
from captcha.models import CaptchaStore
1112
from django.core.exceptions import ValidationError
1213
from django.core.serializers.json import DjangoJSONEncoder
1314
from django.db import models
@@ -213,3 +214,19 @@ def to_groups[T](features: list[T], group_size: int, start_index: int = 100):
213214

214215
def get_random_string(length: int) -> str:
215216
return "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(length))
217+
218+
219+
def validate_captcha(hashkey: str, code: str):
220+
"""Raise ValidationError if captcha invalid."""
221+
if not hashkey or not code:
222+
raise ValidationError("CAPTCHA is required.")
223+
224+
try:
225+
captcha = CaptchaStore.objects.get(hashkey=hashkey)
226+
except CaptchaStore.DoesNotExist:
227+
raise ValidationError("Invalid or expired CAPTCHA") # noqa: B904
228+
229+
if captcha.response.lower() != code.lower():
230+
raise ValidationError("Invalid CAPTCHA")
231+
232+
captcha.delete() # NOTE: Delete after successful validation

0 commit comments

Comments
 (0)