├── .gitignore ├── .travis.yml ├── AUTHORS ├── COPYRIGHT ├── MANIFEST.in ├── README.rst ├── requirements └── test.txt ├── runshell.py ├── runtests.py ├── setup.py ├── simplemathcaptcha ├── __init__.py ├── fields.py ├── locale │ ├── es │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── fr │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── templates │ └── simplemathcaptcha │ │ └── captcha.html ├── utils.py └── widgets.py └── test_simplemathcaptcha ├── __init__.py ├── field_tests.py ├── form_tests.py ├── models.py ├── tests.py ├── utils_tests.py └── widget_tests.py /.gitignore: -------------------------------------------------------------------------------- 1 | .project 2 | .pydevproject 3 | .settings 4 | .coverage 5 | *.py[c|o] 6 | *.sublime-* 7 | build 8 | django_simple_math_captcha.egg-info 9 | dist 10 | .DS_Store 11 | .vscode 12 | __pycache__ 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "3.5" 5 | - "3.6" 6 | - "3.7" 7 | - "3.8" 8 | 9 | env: 10 | - DJANGO=django==2.2 11 | - DJANGO=django==3.0 12 | - DJANGO=django==3.1 13 | install: 14 | - pip install $DJANGO 15 | 16 | script: 17 | - python runtests.py 18 | 19 | branches: 20 | only: 21 | - master 22 | 23 | jobs: 24 | exclude: 25 | - python: "3.5" 26 | env: DJANGO=django==3.0 27 | - python: "3.5" 28 | env: DJANGO=django==3.1 29 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | This software is maintained by: 2 | Brandon Taylor 3 | Luis Zárate 4 | -------------------------------------------------------------------------------- /COPYRIGHT: -------------------------------------------------------------------------------- 1 | (c) Copyright 2011 Brandon Taylor - https://alsoicode.github.io 2 | 3 | django-simple-math-captcha is free software: you can 4 | redistribute it and/or modify it under 5 | the terms of the Apache Public License v2. 6 | 7 | django-admin-sortable is distributed in the hope 8 | that it will be useful, but WITHOUT ANY 9 | WARRANTY; without even the implied 10 | warranty of MERCHANTABILITY or FITNESS 11 | FOR A PARTICULAR PURPOSE. 12 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include simplemathcaptcha/locale * 2 | recursive-include simplemathcaptcha/templates * 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ========================== 2 | django-simple-math-captcha 3 | ========================== 4 | 5 | Current Version: 2.0.0 6 | 7 | For Django 4, use 2.0.0. For Django < 4, use 1.1.0 8 | 9 | What is it? 10 | =========== 11 | A multi-value-field that presents a human answerable question, 12 | with no settings.py configuration necessary, but instead can be 13 | configured with arguments to the field constructor. 14 | 15 | Installation 16 | ============ 17 | You can do any of the following to install ``django-simple-math-captcha`` 18 | 19 | - Run ``pip install django-simple-math-captcha``. 20 | - Download or "git clone" the package and run ``setup.py``. 21 | - Download or "git clone" the package and put the ``simplemathcaptcha`` 22 | directory on your ``PYTHONPATH``. 23 | 24 | Add `simplemathcaptcha` to your INSTALLED_APPS in django settings 25 | 26 | Usage 27 | ===== 28 | 29 | Forms 30 | ----- 31 | To add the captcha field to your form: 32 | 33 | .. code-block:: python 34 | 35 | from django import forms 36 | from simplemathcaptcha.fields import MathCaptchaField 37 | 38 | class MyForm(forms.Form): 39 | some_text_field = models.CharField(max_length=50) 40 | captcha = MathCaptchaField() 41 | 42 | Optionally, you can pass in the following arguments to the field to configure it. 43 | 44 | start_int 45 | The number at which the field should begin its range of random numbers. 46 | This value will be used passed into the creation of a 47 | ``simplemathcaptcha.widgets.MathCaptchaWidget`` for this field. 48 | 49 | Default value: ``1`` 50 | 51 | end_int 52 | The number at which the field should end its range of random numbers. 53 | This value will be used passed into the creation of an 54 | ``simplemathcaptcha.widgets.MathCaptchaWidget`` for this field. 55 | 56 | Default value: ``10`` 57 | 58 | error_messages 59 | A dictionary of error messages. The keys you can use are ``invalid`` 60 | and ``invalid_number``. 61 | 62 | invalid 63 | is the message to display when the provided answer is incorrect 64 | 65 | Default value: ``"Check your math and try again."`` 66 | 67 | invalid_number 68 | is the message to display when the entry is not a whole 69 | number 70 | 71 | Default value: ``"Enter a whole number."`` 72 | 73 | question_tmpl 74 | A string with format placeholders to use for the displayed question. 75 | 76 | Default value: ``"What is %(num1)i %(operator)s %(num2)i?"`` 77 | 78 | question_class 79 | A css class to use for the span containing the displayed question. 80 | 81 | Default value: ``"captcha-question"`` 82 | 83 | widget 84 | The widget instance to use, instead of the field creating an instance 85 | of ``simplemathcaptcha.widgets.MathCaptchaWidget``. When provided, 86 | it must be an instatiated widget, not a widget class. 87 | 88 | Additionally, when specifying ``widget``, you must not specify 89 | ``start_int`` or ``end_int``. 90 | 91 | Default value: ``None`` 92 | 93 | 94 | Rationale 95 | ========= 96 | Other math captcha fields can present questions that require decimal answers, 97 | answers that could be negative values and that require settings.py configuration. 98 | This project aims to provide a single field with minimal or no configuration 99 | necessary and reduce or prevent spam form submissions. 100 | 101 | Status 102 | ====== 103 | django-simple-math-captcha is currently used in production. 104 | 105 | Features 106 | ========= 107 | - Simple addition, subtraction or multiplication question for captcha 108 | - No configuration necessary 109 | - Question asked changes with every render 110 | - Uses SHA1 hashing of answer with your SECRET_KEY 111 | - Unit tests are provided in the source 112 | 113 | Compatibility 114 | ============= 115 | 116 | For any version of Django that doesn't support Python 3, use django-simple-math-captcha 1.0.9 or below, otherwise use 1.1.0 or above. 117 | 118 | License 119 | ======= 120 | The django-simple-math-captcha app is released under the Apache Public License v2. 121 | -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | Django 2 | -------------------------------------------------------------------------------- /runshell.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from django.conf import settings 4 | from django.core.management import execute_from_command_line 5 | 6 | if not settings.configured: 7 | settings.configure( 8 | DATABASES={ 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': ':memory:', 12 | }, 13 | }, 14 | INSTALLED_APPS=(), 15 | ROOT_URLCONF=None, 16 | USE_TZ=True, 17 | SECRET_KEY='foobar' 18 | ) 19 | 20 | 21 | def runshell(): 22 | argv = sys.argv[:1] + ['shell'] + sys.argv[1:] 23 | execute_from_command_line(argv) 24 | 25 | 26 | if __name__ == '__main__': 27 | runshell() 28 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import sys 3 | from django.conf import settings 4 | from django.core.management import execute_from_command_line 5 | 6 | if not settings.configured: 7 | settings.configure( 8 | DATABASES={ 9 | 'default': { 10 | 'ENGINE': 'django.db.backends.sqlite3', 11 | 'NAME': ':memory:', 12 | }, 13 | }, 14 | INSTALLED_APPS=( 15 | 'simplemathcaptcha', 16 | 'test_simplemathcaptcha', 17 | ), 18 | ROOT_URLCONF=None, 19 | USE_TZ=True, 20 | SECRET_KEY='foobar', 21 | ) 22 | 23 | 24 | def runtests(): 25 | argv = sys.argv[:1] + ['test'] + sys.argv[1:] 26 | execute_from_command_line(argv) 27 | 28 | 29 | if __name__ == '__main__': 30 | runtests() 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | try: 4 | README = open('README.rst').read() 5 | except: 6 | README = None 7 | 8 | setup( 9 | name='django-simple-math-captcha', 10 | version=__import__('simplemathcaptcha').__version__, 11 | description='An easy-to-use math field/widget captcha for Django forms.', 12 | long_description=README, 13 | author='Brandon Taylor', 14 | author_email='alsoicode@gmail.com', 15 | url='https://alsoicode.github.io/', 16 | packages=find_packages(), 17 | zip_safe=False, 18 | include_package_data=True, 19 | classifiers=['Development Status :: 5 - Production/Stable', 20 | 'Environment :: Web Environment', 21 | 'Framework :: Django', 22 | 'Intended Audience :: Developers', 23 | 'License :: OSI Approved :: Apache Software License', 24 | 'Operating System :: OS Independent', 25 | 'Programming Language :: Python', 26 | 'Programming Language :: Python :: 2.7', 27 | 'Programming Language :: Python :: 3', 28 | 'Programming Language :: Python :: Implementation :: CPython', 29 | 'Topic :: Utilities'], 30 | ) 31 | -------------------------------------------------------------------------------- /simplemathcaptcha/__init__.py: -------------------------------------------------------------------------------- 1 | VERSION = (2, 0, 0) 2 | DEV_N = None 3 | 4 | 5 | def get_version(): 6 | version = '{0}.{1}'.format(VERSION[0], VERSION[1]) 7 | if VERSION[2]: 8 | version = '{0}.{1}'.format(version, VERSION[2]) 9 | try: 10 | if VERSION[3]: 11 | version = '{0}.{1}'.format(version, VERSION[3]) 12 | except IndexError: 13 | pass 14 | return version 15 | 16 | 17 | __version__ = get_version() 18 | -------------------------------------------------------------------------------- /simplemathcaptcha/fields.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy 3 | from django.core.exceptions import ValidationError 4 | 5 | from .widgets import MathCaptchaWidget 6 | from .utils import hash_answer 7 | 8 | 9 | class MathCaptchaField(forms.MultiValueField): 10 | default_error_messages = { 11 | 'invalid': gettext_lazy('Please check your math and try again.'), 12 | 'invalid_number': gettext_lazy('Enter a whole number.'), 13 | } 14 | 15 | def __init__(self, *args, **kwargs): 16 | self._ensure_widget(kwargs) 17 | kwargs['required'] = True 18 | # we skip MultiValueField handling of fields and setup ourselves 19 | super(MathCaptchaField, self).__init__((), *args, **kwargs) 20 | self._setup_fields() 21 | 22 | def compress(self, data_list): 23 | """Compress takes the place of clean with MultiValueFields""" 24 | if data_list: 25 | answer = data_list[0] 26 | real_hashed_answer = data_list[1] 27 | hashed_answer = hash_answer(answer) 28 | if hashed_answer != real_hashed_answer: 29 | raise ValidationError(self.error_messages['invalid']) 30 | return None 31 | 32 | def _ensure_widget(self, kwargs): 33 | widget_params = self._extract_widget_params(kwargs) 34 | 35 | if 'widget' not in kwargs or not kwargs['widget']: 36 | kwargs['widget'] = MathCaptchaWidget(**widget_params) 37 | elif widget_params: 38 | msg = gettext_lazy('%(params)s must be omitted when widget is provided for %(name)s.') 39 | msg = msg % {'params': ' and '.join(list(widget_params)), 40 | 'name': self.__class__.__name__} 41 | raise TypeError(msg) 42 | 43 | def _extract_widget_params(self, kwargs): 44 | params = {} 45 | for key in ('start_int', 'end_int'): 46 | if key in kwargs: 47 | params[key] = kwargs.pop(key) 48 | return params 49 | 50 | def _setup_fields(self): 51 | error_messages = {'invalid': self.error_messages['invalid_number']} 52 | # set fields 53 | fields = ( 54 | forms.IntegerField(error_messages=error_messages, 55 | localize=self.localize), 56 | forms.CharField() 57 | ) 58 | for field in fields: 59 | field.required = False 60 | self.fields = fields 61 | -------------------------------------------------------------------------------- /simplemathcaptcha/locale/es/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsoicode/django-simple-math-captcha/61adb4f43bfc654da61fa7b84ea4f455e31f0bd2/simplemathcaptcha/locale/es/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simplemathcaptcha/locale/es/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: simplecaptcha\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-08-30 18:13-0600\n" 11 | "PO-Revision-Date: 2015-08-30 18:18-0600\n" 12 | "Last-Translator: Luis Zárate \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: es\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 1.6.10\n" 20 | 21 | #: fields.py:14 22 | msgid "Please check your math and try again." 23 | msgstr "Por favor revise la ecuación y trate de nuevo" 24 | 25 | #: fields.py:15 26 | msgid "Enter a whole number." 27 | msgstr "Ingrese un número entero" 28 | 29 | #: fields.py:41 30 | #, python-format 31 | msgid "%(params)s must be omitted when widget is provided for %(name)s." 32 | msgstr "" 33 | "%(params)s serán omitidos cuando se proporcione un widget para %(name)s." 34 | 35 | #: widgets.py:17 36 | #, python-format 37 | msgid "What is %(num1)i %(operator)s %(num2)i? " 38 | msgstr "¿Cuánto es %(num1)i %(operator)s %(num2)i?" 39 | 40 | #~ msgid "" 41 | #~ "MathCaptchaWidget requires positive integers for start_int and end_int." 42 | #~ msgstr "" 43 | #~ "MathCaptchaWidget requiere un número entero positivo para start_int and " 44 | #~ "end_int." 45 | 46 | #~ msgid "MathCaptchaWidget requires end_int be greater than start_int." 47 | #~ msgstr "MathCaptchaWidget end_int debe ser mayor que start_int." 48 | -------------------------------------------------------------------------------- /simplemathcaptcha/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsoicode/django-simple-math-captcha/61adb4f43bfc654da61fa7b84ea4f455e31f0bd2/simplemathcaptcha/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /simplemathcaptcha/locale/fr/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: simplecaptcha\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2015-08-30 18:13-0600\n" 11 | "PO-Revision-Date: 2015-08-30 18:18-0600\n" 12 | "Last-Translator: Thierry BOULOGNE \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: fr\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 2.2\n" 20 | 21 | #: fields.py:14 22 | msgid "Please check your math and try again." 23 | msgstr "Merci de réviser vos maths." 24 | 25 | #: fields.py:15 26 | msgid "Enter a whole number." 27 | msgstr "Merci d'indiquer un nombre entier" 28 | 29 | #: fields.py:41 30 | #, python-format 31 | msgid "%(params)s must be omitted when widget is provided for %(name)s." 32 | msgstr "" 33 | "%(params)s doit être omise quand le widget est activé pour %(name)s." 34 | 35 | #: widgets.py:17 36 | #, python-format 37 | msgid "What is %(num1)i %(operator)s %(num2)i? " 38 | msgstr "Combien font %(num1)i %(operator)s %(num2)i?" 39 | 40 | #~ msgid "" 41 | #~ "MathCaptchaWidget requires positive integers for start_int and end_int." 42 | #~ msgstr "" 43 | #~ "MathCaptchaWidget requiert des entiers positifs pour start_int anetd end_int." 44 | #~ "end_int." 45 | 46 | #~ msgid "MathCaptchaWidget requires end_int be greater than start_int." 47 | #~ msgstr "MathCaptchaWidget requiert que end_int soit plus grand que start_int." 48 | -------------------------------------------------------------------------------- /simplemathcaptcha/templates/simplemathcaptcha/captcha.html: -------------------------------------------------------------------------------- 1 | {{ question_html }}{% include 'django/forms/widgets/multiwidget.html' %} 2 | -------------------------------------------------------------------------------- /simplemathcaptcha/utils.py: -------------------------------------------------------------------------------- 1 | from random import randint, choice 2 | from hashlib import sha1 3 | 4 | from django.conf import settings 5 | 6 | MULTIPLY = '*' 7 | ADD = '+' 8 | SUBTRACT = '-' 9 | CALCULATIONS = { 10 | MULTIPLY: lambda a, b: a * b, 11 | ADD: lambda a, b: a + b, 12 | SUBTRACT: lambda a, b: a - b, 13 | } 14 | OPERATORS = tuple(CALCULATIONS) 15 | 16 | 17 | def hash_answer(value): 18 | answer = str(value) 19 | to_encode = (settings.SECRET_KEY + answer).encode('utf-8') 20 | return sha1(to_encode).hexdigest() 21 | 22 | 23 | def get_operator(): 24 | return choice(OPERATORS) 25 | 26 | 27 | def get_numbers(start_int, end_int, operator): 28 | x = randint(start_int, end_int) 29 | y = randint(start_int, end_int) 30 | 31 | # avoid negative results for subtraction 32 | if y > x and operator == SUBTRACT: 33 | x, y = y, x 34 | 35 | return x, y 36 | 37 | 38 | def calculate(x, y, operator): 39 | func = CALCULATIONS[operator] 40 | total = func(x, y) 41 | return total 42 | -------------------------------------------------------------------------------- /simplemathcaptcha/widgets.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.template.defaultfilters import mark_safe 3 | from django.utils.translation import gettext_lazy as _ 4 | 5 | from .utils import hash_answer, get_operator, get_numbers, calculate 6 | 7 | 8 | class MathCaptchaWidget(forms.MultiWidget): 9 | template_name = "simplemathcaptcha/captcha.html" 10 | 11 | def __init__(self, start_int=1, end_int=10, question_tmpl=None, 12 | question_class=None, attrs=None): 13 | self.start_int, self.end_int = self.verify_numbers(start_int, end_int) 14 | self.question_class = question_class or 'captcha-question' 15 | self.question_tmpl = ( 16 | question_tmpl or _('What is %(num1)i %(operator)s %(num2)i? ')) 17 | self.question_html = None 18 | widget_attrs = {'size': '5'} 19 | widget_attrs.update(attrs or {}) 20 | widgets = ( 21 | # this is the answer input field 22 | forms.TextInput(attrs=widget_attrs), 23 | 24 | # this is the hashed answer field to compare to 25 | forms.HiddenInput() 26 | ) 27 | super(MathCaptchaWidget, self).__init__(widgets, attrs) 28 | 29 | def get_context(self, *args, **kwargs): 30 | context = super(MathCaptchaWidget, self).get_context(*args, **kwargs) 31 | context['question_class'] = self.question_class 32 | context['question_html'] = self.question_html 33 | return context 34 | 35 | def decompress(self, value): 36 | return [None, None] 37 | 38 | def render(self, name, value, attrs=None, renderer=None): 39 | # hash answer and set as the hidden value of form 40 | hashed_answer = self.generate_captcha() 41 | value = ['', hashed_answer] 42 | 43 | return super(MathCaptchaWidget, self).render(name, value, attrs=attrs, renderer=renderer) 44 | 45 | def generate_captcha(self): 46 | # get operator for calculation 47 | operator = get_operator() 48 | 49 | # get integers for calculation 50 | x, y = get_numbers(self.start_int, self.end_int, operator) 51 | 52 | # set question to display in output 53 | self.set_question(x, y, operator) 54 | 55 | # preform the calculation 56 | total = calculate(x, y, operator) 57 | 58 | return hash_answer(total) 59 | 60 | def set_question(self, x, y, operator): 61 | # make multiplication operator more human-readable 62 | operator_for_label = '×' if operator == '*' else operator 63 | question = self.question_tmpl % { 64 | 'num1': x, 65 | 'operator': operator_for_label, 66 | 'num2': y 67 | } 68 | 69 | self.question_html = mark_safe(question) 70 | 71 | def verify_numbers(self, start_int, end_int): 72 | start_int, end_int = int(start_int), int(end_int) 73 | if start_int < 0 or end_int < 0: 74 | raise Warning('MathCaptchaWidget requires positive integers ' 75 | 'for start_int and end_int.') 76 | elif end_int < start_int: 77 | raise Warning('MathCaptchaWidget requires end_int be greater ' 78 | 'than start_int.') 79 | return start_int, end_int 80 | -------------------------------------------------------------------------------- /test_simplemathcaptcha/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsoicode/django-simple-math-captcha/61adb4f43bfc654da61fa7b84ea4f455e31f0bd2/test_simplemathcaptcha/__init__.py -------------------------------------------------------------------------------- /test_simplemathcaptcha/field_tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.test import TestCase 5 | 6 | from simplemathcaptcha.widgets import MathCaptchaWidget 7 | from simplemathcaptcha.fields import MathCaptchaField 8 | 9 | 10 | class FieldTests(TestCase): 11 | 12 | def test_instantiation(self): 13 | f = MathCaptchaField() 14 | self.assertTrue(f.required) 15 | self.assertTrue(f.widget.is_required) 16 | self.assertEqual(len(f.fields), 2) 17 | 18 | @mock.patch('simplemathcaptcha.fields.MathCaptchaWidget') 19 | def test_instantiation_with_values(self, mocked): 20 | MathCaptchaField(start_int=5, end_int=10) 21 | mocked.assert_called_once_with(start_int=5, end_int=10) 22 | 23 | @mock.patch('simplemathcaptcha.fields.MathCaptchaWidget') 24 | def test_instantiation_with_widget(self, mocked): 25 | MathCaptchaField(widget=MathCaptchaWidget()) 26 | self.assertEqual(mocked.call_count, 0) 27 | 28 | def test_instantiation_with_widget_and_values_is_error(self): 29 | with self.assertRaises(TypeError): 30 | MathCaptchaField(start_int=5, end_int=10, 31 | widget=MathCaptchaWidget()) 32 | 33 | def test_compress(self): 34 | f = MathCaptchaField() 35 | with mock.patch('simplemathcaptcha.fields.hash_answer') as mocked: 36 | mocked.return_value = 'hashed_answer' 37 | result = f.compress(['abc', 'hashed_answer']) 38 | self.assertIsNone(result) 39 | 40 | def test_compress_with_wrong_answer(self): 41 | f = MathCaptchaField() 42 | with mock.patch('simplemathcaptcha.fields.hash_answer') as mocked: 43 | mocked.return_value = 'bad_hashed_answer' 44 | with self.assertRaises(ValidationError): 45 | f.compress(['abc', 'hashed_answer']) 46 | 47 | def test_compress_with_nothing(self): 48 | f = MathCaptchaField() 49 | result = f.compress([]) 50 | self.assertIsNone(result) 51 | -------------------------------------------------------------------------------- /test_simplemathcaptcha/form_tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django import forms 4 | from django.test import TestCase 5 | 6 | from simplemathcaptcha.fields import MathCaptchaField 7 | from simplemathcaptcha import utils 8 | 9 | 10 | class FormTests(TestCase): 11 | 12 | @mock.patch('simplemathcaptcha.widgets.get_operator') 13 | @mock.patch('simplemathcaptcha.widgets.hash_answer') 14 | @mock.patch('simplemathcaptcha.utils.randint') 15 | def test_form(self, mock_randint, mock_hash_answer, mock_get_operator): 16 | 17 | ops = ['+', '-'] 18 | ints = [1, 3, 1, 3] 19 | mock_randint.side_effect = lambda x, y: ints.pop(0) 20 | mock_hash_answer.side_effect = lambda x: "answer=%s" % x 21 | mock_get_operator.side_effect = lambda: ops.pop(0) 22 | 23 | class F(forms.Form): 24 | captcha = MathCaptchaField() 25 | 26 | f = F() 27 | result1 = str(f) 28 | result2 = str(f) 29 | self.assertHTMLEqual(result1, """ 30 | 31 | What is 1 + 3? 32 | 34 | 36 | """) 37 | 38 | self.assertHTMLEqual(result2, """ 39 | 40 | What is 3 - 1? 41 | 43 | 45 | """) 46 | 47 | def test_form_validation(self): 48 | class F(forms.Form): 49 | captcha = MathCaptchaField() 50 | 51 | hashed_answer = utils.hash_answer(5) 52 | 53 | f = F({'captcha_0': 5, 'captcha_1': hashed_answer}) 54 | self.assertTrue(f.is_valid()) 55 | 56 | f = F({'captcha_0': 4, 'captcha_1': hashed_answer}) 57 | self.assertFalse(f.is_valid()) 58 | -------------------------------------------------------------------------------- /test_simplemathcaptcha/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alsoicode/django-simple-math-captcha/61adb4f43bfc654da61fa7b84ea4f455e31f0bd2/test_simplemathcaptcha/models.py -------------------------------------------------------------------------------- /test_simplemathcaptcha/tests.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from __future__ import absolute_import 3 | 4 | from .utils_tests import UtilsTests 5 | from .widget_tests import WidgetTests 6 | from .field_tests import FieldTests 7 | from .form_tests import FormTests 8 | -------------------------------------------------------------------------------- /test_simplemathcaptcha/utils_tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | 5 | from simplemathcaptcha import utils 6 | 7 | 8 | class UtilsTests(TestCase): 9 | 10 | def test_get_numbers(self): 11 | x, y = utils.get_numbers(1, 1, '+') 12 | self.assertEqual((x, y), (1, 1)) 13 | 14 | @mock.patch('simplemathcaptcha.utils.randint') 15 | def test_get_numbers_changes_order(self, mocked): 16 | vals = [1, 5] 17 | mocked.side_effect = lambda a, b: vals.pop(0) 18 | x, y = utils.get_numbers(1, 5, '-') 19 | self.assertEqual((x, y), (5, 1)) 20 | 21 | @mock.patch('simplemathcaptcha.utils.randint') 22 | def test_get_numbers_does_not_change_order(self, mocked): 23 | vals = [1, 5, 1, 5] 24 | mocked.side_effect = lambda a, b: vals.pop(0) 25 | x, y = utils.get_numbers(1, 5, '+') 26 | self.assertEqual((x, y), (1, 5)) 27 | x, y = utils.get_numbers(1, 5, '*') 28 | self.assertEqual((x, y), (1, 5)) 29 | 30 | def test_calculate_adding(self): 31 | result = utils.calculate(1, 1, '+') 32 | self.assertEqual(result, 2) 33 | 34 | def test_calculate_subtracting(self): 35 | result = utils.calculate(1, 1, '-') 36 | self.assertEqual(result, 0) 37 | 38 | def test_calculate_multiplying(self): 39 | result = utils.calculate(1, 1, '*') 40 | self.assertEqual(result, 1) 41 | 42 | def test_calculate_raises_on_unknown_op(self): 43 | with self.assertRaises(KeyError): 44 | utils.calculate(1, 1, '/') 45 | 46 | def test_hash_answer_is_string(self): 47 | result = utils.hash_answer(1) 48 | self.assertIsInstance(result, str) 49 | 50 | def test_hash_answer_is_repeatable(self): 51 | result1 = utils.hash_answer(1) 52 | result2 = utils.hash_answer(1) 53 | self.assertEqual(result1, result2) 54 | 55 | def test_hash_answer_returns_hexdigest(self): 56 | result = utils.hash_answer(1) 57 | self.assertEqual(len(result), 40) 58 | -------------------------------------------------------------------------------- /test_simplemathcaptcha/widget_tests.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | from django.test import TestCase 4 | 5 | from simplemathcaptcha.widgets import MathCaptchaWidget 6 | 7 | 8 | class WidgetTests(TestCase): 9 | 10 | # init 11 | def test_instantiation(self): 12 | w = MathCaptchaWidget(1, 10) 13 | self.assertEqual(len(w.widgets), 2) 14 | self.assertEqual(w.start_int, 1) 15 | self.assertEqual(w.end_int, 10) 16 | self.assertEqual(str(w.question_tmpl), 17 | 'What is %(num1)i %(operator)s %(num2)i? ') 18 | self.assertEqual(w.question_class, 'captcha-question') 19 | 20 | def test_default_question_tmpl(self): 21 | w = MathCaptchaWidget(question_tmpl='foo') 22 | self.assertEqual(w.question_tmpl, 'foo') 23 | 24 | def test_default_question_class(self): 25 | w = MathCaptchaWidget(question_class='foo') 26 | self.assertEqual(w.question_class, 'foo') 27 | 28 | def test_negative_start_int_raises(self): 29 | with self.assertRaises(Warning): 30 | MathCaptchaWidget(-1, 10) 31 | 32 | def test_negative_end_int_raises(self): 33 | with self.assertRaises(Warning): 34 | MathCaptchaWidget(1, -10) 35 | 36 | def test_end_int_less_than_start_int_raises(self): 37 | with self.assertRaises(Warning): 38 | MathCaptchaWidget(10, 1) 39 | 40 | # decompress tests 41 | def test_decompress_always_returns_none(self): 42 | w = MathCaptchaWidget() 43 | expected = [None, None] 44 | self.assertEqual(w.decompress(None), expected) 45 | self.assertEqual(w.decompress(''), expected) 46 | self.assertEqual(w.decompress('something'), expected) 47 | 48 | # render tests 49 | def test_render(self): 50 | w = MathCaptchaWidget() 51 | with mock.patch.object(w, 'generate_captcha') as mock_generate_captcha: 52 | mock_generate_captcha.return_value = 'hashed_answer' 53 | w.question_html = 'question_html' 54 | result = w.render('foo', None) 55 | self.assertHTMLEqual(result, """ 56 | question_html 57 | 58 | """) 59 | 60 | def test_render_is_different_each_time_called(self): 61 | w = MathCaptchaWidget() 62 | result1 = w.render('foo', None) 63 | result2 = w.render('foo', None) 64 | self.assertHTMLNotEqual(result1, result2) 65 | 66 | # generate captcha tests 67 | @mock.patch('simplemathcaptcha.widgets.get_operator') 68 | @mock.patch('simplemathcaptcha.widgets.get_numbers') 69 | @mock.patch('simplemathcaptcha.widgets.hash_answer') 70 | def test_generate_captcha(self, mock_hash_answer, mock_get_numbers, 71 | mock_get_operator): 72 | mock_hash_answer.side_effect = lambda x: x 73 | mock_get_numbers.return_value = (1, 3) 74 | mock_get_operator.return_value = '+' 75 | w = MathCaptchaWidget() 76 | result = w.generate_captcha() 77 | self.assertEqual(result, 4) 78 | self.assertEqual(mock_hash_answer.call_count, 1) 79 | self.assertEqual(mock_get_numbers.call_count, 1) 80 | self.assertEqual(mock_get_operator.call_count, 1) 81 | self.assertHTMLEqual(w.question_html, "What is 1 + 3?") 82 | 83 | # set question tests 84 | def test_set_question(self): 85 | w = MathCaptchaWidget() 86 | w.set_question(2, 4, 'foo') 87 | self.assertHTMLEqual(w.question_html, "What is 2 foo 4?") 88 | 89 | def test_set_question_converts_multiplication_operator_to_entity(self): 90 | w = MathCaptchaWidget() 91 | w.set_question(2, 4, '*') 92 | self.assertHTMLEqual(w.question_html, "What is 2 × 4?") 93 | --------------------------------------------------------------------------------