Skip to content

Commit

Permalink
[#2752] Add math captcha to contactform
Browse files Browse the repository at this point in the history
  • Loading branch information
pi-sigma committed Oct 1, 2024
1 parent fdb51bb commit 211fcb0
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,18 @@
{% input form_object.phonenumber %}
{% endif %}
{% input form_object.question %}

{% if form_object.captcha %}
<span class="label__label">
{{ form_object.captcha.label }}<span class="label__label--required"> * </span>
</span>
<span>{{ form_object.captcha_text }}</span>
<span>{{ form_object.captcha.question }}</span>
{% field_as_widget form_object.captcha "input" form_id %}
{% endif %}

{% form_actions primary_text=_("Verzenden") primary_icon="arrow_forward" %}

{% endrender_form %}

{% else %}
Expand Down
10 changes: 8 additions & 2 deletions src/open_inwoner/openklant/forms.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from django import forms
from django.forms import Form
from django.utils.translation import gettext_lazy as _

from open_inwoner.accounts.models import User
from open_inwoner.openklant.models import ContactFormSubject, OpenKlantConfig
from open_inwoner.utils.forms import MathCaptchaField
from open_inwoner.utils.validators import DutchPhoneNumberValidator


class ContactForm(Form):
class ContactForm(forms.Form):
subject = forms.ModelChoiceField(
label=_("Onderwerp"),
required=True,
Expand Down Expand Up @@ -45,6 +45,10 @@ class ContactForm(Form):
widget=forms.Textarea(attrs={"rows": "5"}),
required=True,
)
captcha = MathCaptchaField(
label=_("Beantwoord deze rekensom"),
required=True,
)

user: User

Expand All @@ -54,11 +58,13 @@ def __init__(self, user, *args, **kwargs):

config = OpenKlantConfig.get_solo()
self.fields["subject"].queryset = config.contactformsubject_set.all()
self.captcha_text = self.fields["captcha"].question

if self.user.is_authenticated:
del self.fields["first_name"]
del self.fields["last_name"]
del self.fields["infix"]
del self.fields["captcha"]
if self.user.email:
del self.fields["email"]
if self.user.phonenumber:
Expand Down
53 changes: 39 additions & 14 deletions src/open_inwoner/openklant/tests/test_contactform.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from open_inwoner.openklant.tests.data import MockAPICreateData
from open_inwoner.openklant.tests.factories import ContactFormSubjectFactory
from open_inwoner.openzaak.tests.factories import ServiceFactory
from open_inwoner.utils.forms import MathCaptchaField
from open_inwoner.utils.test import ClearCachesMixin, DisableRequestLogMixin
from open_inwoner.utils.tests.helpers import AssertFormMixin, AssertTimelineLogMixin

Expand All @@ -24,6 +25,7 @@
@modify_settings(
MIDDLEWARE={"remove": ["open_inwoner.kvk.middleware.KvKLoginMiddleware"]}
)
@patch.object(MathCaptchaField, "clean")
@patch(
"open_inwoner.openklant.views.contactform.send_contact_confirmation_mail",
autospec=True,
Expand Down Expand Up @@ -55,7 +57,9 @@ def setUp(self):
config.send_email_confirmation = True
config.save()

def test_singleton_has_configuration_method(self, m, mock_send_confirm):
def test_singleton_has_configuration_method(
self, m, mock_send_confirm, mock_captcha
):
# use cleared (from setUp()
config = OpenKlantConfig.get_solo()
self.assertFalse(config.has_form_configuration())
Expand All @@ -81,7 +85,9 @@ def test_singleton_has_configuration_method(self, m, mock_send_confirm):

mock_send_confirm.assert_not_called()

def test_no_form_shown_if_not_has_configuration(self, m, mock_send_confirm):
def test_no_form_shown_if_not_has_configuration(
self, m, mock_send_confirm, mock_captcha
):
# set nothing
config = OpenKlantConfig.get_solo()
self.assertFalse(config.has_form_configuration())
Expand All @@ -90,7 +96,9 @@ def test_no_form_shown_if_not_has_configuration(self, m, mock_send_confirm):
self.assertContains(response, _("Contact formulier niet geconfigureerd."))
self.assertEqual(0, len(response.pyquery("#contactmoment-form")))

def test_anon_form_requires_either_email_or_phonenumber(self, m, mock_send_confirm):
def test_anon_form_requires_either_email_or_phonenumber(
self, m, mock_send_confirm, mock_captcha
):
config = OpenKlantConfig.get_solo()
config.register_email = "[email protected]"
config.save()
Expand All @@ -108,6 +116,7 @@ def test_anon_form_requires_either_email_or_phonenumber(self, m, mock_send_confi
"email",
"phonenumber",
"question",
"captcha", # captcha present for anon user
),
)
form["subject"].select(text=subject.subject)
Expand All @@ -123,7 +132,9 @@ def test_anon_form_requires_either_email_or_phonenumber(self, m, mock_send_confi
)
mock_send_confirm.assert_not_called()

def test_regular_auth_form_fills_email_and_phonenumber(self, m, mock_send_confirm):
def test_regular_auth_form_fills_email_and_phonenumber(
self, m, mock_send_confirm, mock_captcha
):
config = OpenKlantConfig.get_solo()
config.register_email = "[email protected]"
config.save()
Expand All @@ -146,7 +157,9 @@ def test_regular_auth_form_fills_email_and_phonenumber(self, m, mock_send_confir
response = form.submit(status=302)
mock_send_confirm.assert_called_once_with(user.email, subject.subject)

def test_expected_ordered_subjects_are_shown(self, m, mock_send_confirm):
def test_expected_ordered_subjects_are_shown(
self, m, mock_send_confirm, mock_captcha
):
config = OpenKlantConfig.get_solo()
config.register_email = "[email protected]"
config.save()
Expand Down Expand Up @@ -183,7 +196,7 @@ def test_expected_ordered_subjects_are_shown(self, m, mock_send_confirm):
)
mock_send_confirm.assert_not_called()

def test_submit_and_register_via_email(self, m, mock_send_confirm):
def test_submit_and_register_via_email(self, m, mock_send_confirm, mock_captcha):
config = OpenKlantConfig.get_solo()
config.register_email = "[email protected]"
config.has_form_configuration = True
Expand Down Expand Up @@ -223,7 +236,9 @@ def test_submit_and_register_via_email(self, m, mock_send_confirm):

mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)

def test_submit_and_register_anon_via_api_with_klant(self, m, mock_send_confirm):
def test_submit_and_register_anon_via_api_with_klant(
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down Expand Up @@ -306,7 +321,9 @@ def test_submit_and_register_anon_via_api_with_klant(self, m, mock_send_confirm)

mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)

def test_submit_and_register_anon_via_api_without_klant(self, m, mock_send_confirm):
def test_submit_and_register_anon_via_api_without_klant(
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down Expand Up @@ -381,7 +398,9 @@ def test_submit_and_register_anon_via_api_without_klant(self, m, mock_send_confi
self.assertTimelineLog("registered contactmoment by API")
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)

def test_register_bsn_user_via_api_without_id(self, m, mock_send_confirm):
def test_register_bsn_user_via_api_without_id(
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down Expand Up @@ -432,7 +451,9 @@ def test_register_bsn_user_via_api_without_id(self, m, mock_send_confirm):
},
)

def test_submit_and_register_bsn_user_via_api(self, m, mock_send_confirm):
def test_submit_and_register_bsn_user_via_api(
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down Expand Up @@ -506,7 +527,9 @@ def test_submit_and_register_bsn_user_via_api(self, m, mock_send_confirm):
self.assertTimelineLog("registered contactmoment by API")
mock_send_confirm.assert_called_once_with("[email protected]", subject.subject)

def test_submit_and_register_kvk_or_rsin_user_via_api(self, _m, mock_send_confirm):
def test_submit_and_register_kvk_or_rsin_user_via_api(
self, _m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down Expand Up @@ -602,7 +625,7 @@ def test_submit_and_register_kvk_or_rsin_user_via_api(self, _m, mock_send_confir
mock_send_confirm.reset_mock()

def test_submit_and_register_bsn_user_via_api_and_update_klant(
self, m, mock_send_confirm
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

Expand Down Expand Up @@ -686,7 +709,7 @@ def test_submit_and_register_bsn_user_via_api_and_update_klant(
mock_send_confirm.reset_mock()

def test_submit_and_register_kvk_or_rsin_user_via_api_and_update_klant(
self, _m, mock_send_confirm
self, m, mock_send_confirm, mock_captcha
):
self.maxDiff = None
MockAPICreateData.setUpServices()
Expand Down Expand Up @@ -789,7 +812,9 @@ def test_submit_and_register_kvk_or_rsin_user_via_api_and_update_klant(
)
mock_send_confirm.reset_mock()

def test_send_email_confirmation_is_configurable(self, m, mock_send_confirm):
def test_send_email_confirmation_is_configurable(
self, m, mock_send_confirm, mock_captcha
):
MockAPICreateData.setUpServices()

config = OpenKlantConfig.get_solo()
Expand Down
49 changes: 49 additions & 0 deletions src/open_inwoner/utils/forms.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import mimetypes
import random

from django import forms
from django.conf import settings
Expand Down Expand Up @@ -163,3 +164,51 @@ def clean(self, *args, **kwargs):
)

return f


class MathCaptchaField(forms.Field):
def __init__(
self,
range_: tuple = (1, 10),
operators: list[str] | None = None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.widget = forms.TextInput()
self.range_ = range_
self.operators = operators or ["+", "-"]
self.question, self.answer = self.generate_question_answer_pair(
self.range_, self.operators
)

@staticmethod
def generate_question_answer_pair(
range_: tuple[int, int],
operators: list[str],
) -> tuple[str, int]:
lower, upper = range_
num1 = random.randint(lower, upper) # nosec
num2 = random.randint(lower, upper) # nosec
operator = random.choice(operators) # nosec

# exclude negative results
num1, num2 = max(num1, num2), min(num1, num2)

question = _("What is {num1} {operator_str} {num2}?").format(
num1=num1, operator_str=operator, num2=num2
)
answer = eval(f"{num1} {operator} {num2}") # nosec

return question, answer

def clean(self, value: str) -> str:
if not value:
raise forms.ValidationError(_("Dit veld is vereist."))
if not isinstance(value, str):
raise forms.ValidationError(_("Voer een geheel getal in."))
if value.isspace():
raise forms.ValidationError(_("Voer een geheel getal in."))
if int(value) != self.answer:
raise forms.ValidationError(_("Fout antwoord, probeer het opnieuw."))
return value
52 changes: 52 additions & 0 deletions src/open_inwoner/utils/tests/test_form_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from django import forms
from django.test import TestCase
from django.utils.translation import gettext as _

from ..forms import MathCaptchaField


class MockForm(forms.Form):
captcha = MathCaptchaField(range_=(4, 4), operators=["+"])
captcha_2 = MathCaptchaField(range_=(4, 4), operators=["-"])


class MathCaptchaFieldUnitTest(TestCase):
def test_captcha_invalid(self):
test_cases = [
{
"captcha": "",
"message": _("Dit veld is vereist."),
"reason": "field required",
},
{
"captcha": " ",
"message": _("Voer een geheel getal in."),
"reason": "wrong input type",
},
{
"captcha": 42,
"message": _("Voer een geheel getal in."),
"reason": "wrong input type",
},
{
"captcha": "42", # captcha only computes 2 numbers between 1 and 10
"message": _("Fout antwoord, probeer het opnieuw."),
"reason": "wrong answer",
},
]
for test_case in test_cases:
with self.subTest(reason=test_case["reason"]):
form = MockForm(
data={
"captcha": test_case["captcha"],
"captcha_2": test_case["captcha"],
},
)
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["captcha"], [test_case["message"]])

def test_captcha_valid(self):
form = MockForm(
data={"captcha": "8", "captcha_2": "0"},
)
self.assertTrue(form.is_valid())

0 comments on commit 211fcb0

Please sign in to comment.