From aedb514b7fb099e0746e23b754d11b5a099c414d Mon Sep 17 00:00:00 2001 From: michal dub Date: Mon, 18 Jan 2016 23:39:18 +0100 Subject: [PATCH 1/9] added storages package with db and cache storage --- captcha/storages/__init__.py | 27 +++++++++++++ captcha/storages/base.py | 73 ++++++++++++++++++++++++++++++++++++ captcha/storages/cache.py | 53 ++++++++++++++++++++++++++ captcha/storages/db.py | 30 +++++++++++++++ 4 files changed, 183 insertions(+) create mode 100644 captcha/storages/__init__.py create mode 100644 captcha/storages/base.py create mode 100644 captcha/storages/cache.py create mode 100644 captcha/storages/db.py diff --git a/captcha/storages/__init__.py b/captcha/storages/__init__.py new file mode 100644 index 00000000..044594f4 --- /dev/null +++ b/captcha/storages/__init__.py @@ -0,0 +1,27 @@ +from django.core.exceptions import ImproperlyConfigured +from django.utils.module_loading import import_string + +from ..conf import settings as captcha_settings + + +class InvalidStorageBackendError(ImproperlyConfigured): + pass + + +def get_storage(): + conf = captcha_settings.CAPTCHA_STORAGE + try: + backend = conf['BACKEND'] + # Trying to import the given backend, in case it's a dotted path + backend_cls = import_string(backend) + except (KeyError, ImportError) as e: + raise InvalidStorageBackendError("Could not find storage backend '%s': %s" % ( + backend, e)) + try: + params = conf['PARAMS'].copy() + except KeyError: + params = {} + return backend_cls(params) + + +storage = get_storage() diff --git a/captcha/storages/base.py b/captcha/storages/base.py new file mode 100644 index 00000000..4e24e7f7 --- /dev/null +++ b/captcha/storages/base.py @@ -0,0 +1,73 @@ +import datetime +import random +import time +import hashlib + +from django.utils.encoding import smart_text + +from ..models import CaptchaStore +from ..helpers import get_safe_now +from ..conf import settings as captcha_settings + + +# Heavily based on session key generation in Django +# Use the system (hardware-based) random number generator if it exists. +if hasattr(random, 'SystemRandom'): + randrange = random.SystemRandom().randrange +else: + randrange = random.randrange +MAX_RANDOM_KEY = 18446744073709551616 # 2 << 63 + + +class BaseStorage(object): + model_class = CaptchaStore + + def __init__(self, params): + self.params = params + + def create_obj(self, challenge, response, hashkey, expiration): + raise NotImplemented("Override this method in %s" % self.__class__.__name__) + + def delete(self, hashkey, obj=None): + raise NotImplemented("Override this method in %s" % self.__class__.__name__) + + def create(self, challenge, response, hashkey=None, expiration=None): + response = response.lower() + hashkey = hashkey or self.get_hashkey(challenge, response) + expiration = expiration or self.get_expiration() + return self.create_obj(challenge, response, hashkey, expiration) + + def get(self, hashkey): + raise NotImplemented("Override this method in %s" % self.__class__.__name__) + + def delete_wanted(self, hashkey, response, date_to_compare=None): + date_to_compare = date_to_compare or get_safe_now() + obj = self.get(hashkey) + if obj.response != response or obj.expiration <= date_to_compare: + raise self.model_class.DoesNotExist + self.delete(hashkey, obj=obj) + + def get_hashkey(self, challenge, response): + key_ = ( + smart_text(randrange(0, MAX_RANDOM_KEY)) + + smart_text(time.time()) + + smart_text(challenge, errors='ignore') + + smart_text(response, errors='ignore') + ).encode('utf8') + return hashlib.sha1(key_).hexdigest() + + def get_expiration(self): + return get_safe_now() + datetime.timedelta(minutes=int(captcha_settings.CAPTCHA_TIMEOUT)) + + def remove_expired(self): + raise NotImplemented("Override this method in %s" % self.__class__.__name__) + + def get_count_of_expired(self): + raise NotImplemented("Override this method in %s" % self.__class__.__name__) + + def generate_key(self): + challenge, response = captcha_settings.get_challenge()() + hashkey = self.get_hashkey(challenge, response) + self.create(challenge=challenge, response=response, hashkey=hashkey) + + return hashkey diff --git a/captcha/storages/cache.py b/captcha/storages/cache.py new file mode 100644 index 00000000..8269e0d9 --- /dev/null +++ b/captcha/storages/cache.py @@ -0,0 +1,53 @@ +from django.core.cache import caches + +from ..helpers import get_safe_now +from .base import BaseStorage + + +class CacheStorage(BaseStorage): + key_pattern = "captcha_storage_cache_%s" + + def __init__(self, params): + super(CacheStorage, self).__init__(params) + alias = params.get('ALIAS', 'default') + self.cache = caches[alias] + + def get_key(self, hashkey): + return self.key_pattern % hashkey + + def create_obj(self, challenge, response, hashkey, expiration): + key = self.get_key(hashkey) + data = dict( + challenge=challenge, + response=response, + hashkey=hashkey, + expiration=expiration + ) + self.cache.set(key, data, timeout=self.get_timeout()) + return self.model_class(**data) + + def delete(self, hashkey, obj=None): + key = self.get_key(hashkey) + self.cache.delete(key) + + def get(self, hashkey): + key = self.get_key(hashkey) + data = self.cache.get(key) + if not data: + raise self.model_class.DoesNotExist + return self.model_class(**data) + + def get_timeout(self): + return (self.get_expiration() - get_safe_now()).total_seconds() + + def remove_expired(self): + """ + In cache keys expired automatically + """ + pass + + def get_count_of_expired(self): + """ + undefined for cache + """ + return None diff --git a/captcha/storages/db.py b/captcha/storages/db.py new file mode 100644 index 00000000..8330d5dd --- /dev/null +++ b/captcha/storages/db.py @@ -0,0 +1,30 @@ +from ..helpers import get_safe_now +from .base import BaseStorage + + +class DBStorage(BaseStorage): + + def create_obj(self, challenge, response, hashkey, expiration): + return self.model_class.objects.create( + challenge=challenge, + response=response, + hashkey=hashkey, + expiration=expiration + ) + + def delete(self, hashkey, obj=None): + if not obj: + obj = self.get(hashkey) + obj.delete() + + def get(self, hashkey): + return self.model_class.objects.get(hashkey=hashkey) + + def expired_qs(self): + return self.model_class.objects.filter(expiration__lte=get_safe_now()) + + def remove_expired(self): + self.expired_qs().delete() + + def get_count_of_expired(self): + return self.expired_qs().count() From a22a408f77727706192aaa00d61cd960a2caddce Mon Sep 17 00:00:00 2001 From: michal dub Date: Mon, 18 Jan 2016 23:43:24 +0100 Subject: [PATCH 2/9] implemented storage usage to views, fields and managment command --- captcha/fields.py | 18 +++---- captcha/helpers.py | 18 ++++++- captcha/management/commands/captcha_clean.py | 11 +++-- captcha/models.py | 52 -------------------- captcha/views.py | 28 ++++++----- 5 files changed, 46 insertions(+), 81 deletions(-) diff --git a/captcha/fields.py b/captcha/fields.py index 47f253d4..ac9bc56c 100644 --- a/captcha/fields.py +++ b/captcha/fields.py @@ -1,6 +1,6 @@ from captcha.conf import settings -from captcha.models import CaptchaStore, get_safe_now -from django.core.exceptions import ImproperlyConfigured +from captcha.storages import storage +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist from django.core.urlresolvers import reverse, NoReverseMatch from django.forms import ValidationError from django.forms.fields import CharField, MultiValueField @@ -29,7 +29,7 @@ def decompress(self, value): def fetch_captcha_store(self, name, value, attrs=None): """ - Fetches a new CaptchaStore + Fetches a new captcha record This has to be called inside render """ try: @@ -37,7 +37,7 @@ def fetch_captcha_store(self, name, value, attrs=None): except NoReverseMatch: raise ImproperlyConfigured('Make sure you\'ve included captcha.urls as explained in the INSTALLATION section on http://readthedocs.org/docs/django-simple-captcha/en/latest/usage.html#installation') - key = CaptchaStore.generate_key() + key = storage.generate_key() # these can be used by format_output and render self._value = [key, u('')] @@ -154,20 +154,20 @@ def compress(self, data_list): def clean(self, value): super(CaptchaField, self).clean(value) response, value[1] = (value[1] or '').strip().lower(), '' - CaptchaStore.remove_expired() + storage.remove_expired() if settings.CAPTCHA_TEST_MODE and response.lower() == 'passed': # automatically pass the test try: # try to delete the captcha based on its hash - CaptchaStore.objects.get(hashkey=value[0]).delete() - except CaptchaStore.DoesNotExist: + storage.delete(hashkey=value[0]) + except ObjectDoesNotExist: # ignore errors pass elif not self.required and not response: pass else: try: - CaptchaStore.objects.get(response=response, hashkey=value[0], expiration__gt=get_safe_now()).delete() - except CaptchaStore.DoesNotExist: + storage.delete_wanted(hashkey=value[0], response=response) + except ObjectDoesNotExist: raise ValidationError(getattr(self, 'error_messages', {}).get('invalid', ugettext_lazy('Invalid CAPTCHA'))) return value diff --git a/captcha/helpers.py b/captcha/helpers.py index 1e8902df..30c5a880 100644 --- a/captcha/helpers.py +++ b/captcha/helpers.py @@ -1,9 +1,23 @@ # -*- coding: utf-8 -*- import random -from captcha.conf import settings -from django.core.urlresolvers import reverse +import datetime from six import u, text_type +from django.core.urlresolvers import reverse +from django.conf import settings as dj_settings + +from captcha.conf import settings + + +def get_safe_now(): + try: + from django.utils.timezone import utc + if dj_settings.USE_TZ: + return datetime.datetime.utcnow().replace(tzinfo=utc) + except: + pass + return datetime.datetime.now() + def math_challenge(): operators = ('+', '*', '-',) diff --git a/captcha/management/commands/captcha_clean.py b/captcha/management/commands/captcha_clean.py index 88f28e40..a74c9988 100644 --- a/captcha/management/commands/captcha_clean.py +++ b/captcha/management/commands/captcha_clean.py @@ -1,19 +1,20 @@ -from django.core.management.base import BaseCommand -from captcha.models import get_safe_now import sys +from django.core.management.base import BaseCommand + +from captcha.storages import storage + class Command(BaseCommand): help = "Clean up expired captcha hashkeys." def handle(self, **options): - from captcha.models import CaptchaStore verbose = int(options.get('verbosity')) - expired_keys = CaptchaStore.objects.filter(expiration__lte=get_safe_now()).count() + expired_keys = storage.get_count_of_expired() if verbose >= 1: print("Currently %d expired hashkeys" % expired_keys) try: - CaptchaStore.remove_expired() + storage.remove_expired() except: if verbose >= 1: print("Unable to delete expired hashkeys.") diff --git a/captcha/models.py b/captcha/models.py index 32c79ab7..f0d6eca4 100644 --- a/captcha/models.py +++ b/captcha/models.py @@ -1,30 +1,4 @@ -from captcha.conf import settings as captcha_settings from django.db import models -from django.conf import settings -from django.utils.encoding import smart_text -import datetime -import random -import time -import hashlib - - -# Heavily based on session key generation in Django -# Use the system (hardware-based) random number generator if it exists. -if hasattr(random, 'SystemRandom'): - randrange = random.SystemRandom().randrange -else: - randrange = random.randrange -MAX_RANDOM_KEY = 18446744073709551616 # 2 << 63 - - -def get_safe_now(): - try: - from django.utils.timezone import utc - if settings.USE_TZ: - return datetime.datetime.utcnow().replace(tzinfo=utc) - except: - pass - return datetime.datetime.now() class CaptchaStore(models.Model): @@ -33,31 +7,5 @@ class CaptchaStore(models.Model): hashkey = models.CharField(blank=False, max_length=40, unique=True) expiration = models.DateTimeField(blank=False) - def save(self, *args, **kwargs): - self.response = self.response.lower() - if not self.expiration: - self.expiration = get_safe_now() + datetime.timedelta(minutes=int(captcha_settings.CAPTCHA_TIMEOUT)) - if not self.hashkey: - key_ = ( - smart_text(randrange(0, MAX_RANDOM_KEY)) + - smart_text(time.time()) + - smart_text(self.challenge, errors='ignore') + - smart_text(self.response, errors='ignore') - ).encode('utf8') - self.hashkey = hashlib.sha1(key_).hexdigest() - del(key_) - super(CaptchaStore, self).save(*args, **kwargs) - def __unicode__(self): return self.challenge - - def remove_expired(cls): - cls.objects.filter(expiration__lte=get_safe_now()).delete() - remove_expired = classmethod(remove_expired) - - @classmethod - def generate_key(cls): - challenge, response = captcha_settings.get_challenge()() - store = cls.objects.create(challenge=challenge, response=response) - - return store.hashkey diff --git a/captcha/views.py b/captcha/views.py index c3517de2..44dec8ce 100644 --- a/captcha/views.py +++ b/captcha/views.py @@ -1,14 +1,16 @@ -from captcha.conf import settings -from captcha.helpers import captcha_image_url -from captcha.models import CaptchaStore -from django.http import HttpResponse, Http404 -from django.core.exceptions import ImproperlyConfigured -import random import re -import tempfile import os -import subprocess import six +import random +import tempfile +import subprocess + +from django.http import HttpResponse, Http404 +from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist + +from captcha.conf import settings +from captcha.helpers import captcha_image_url +from captcha.storages import storage try: from cStringIO import StringIO @@ -49,8 +51,8 @@ def makeimg(size): def captcha_image(request, key, scale=1): try: - store = CaptchaStore.objects.get(hashkey=key) - except CaptchaStore.DoesNotExist: + store = storage.get(hashkey=key) + except ObjectDoesNotExist: # HTTP 410 Gone status so that crawlers don't index these expired urls. return HttpResponse(status=410) @@ -134,8 +136,8 @@ def captcha_image(request, key, scale=1): def captcha_audio(request, key): if settings.CAPTCHA_FLITE_PATH: try: - store = CaptchaStore.objects.get(hashkey=key) - except CaptchaStore.DoesNotExist: + store = storage.get(hashkey=key) + except ObjectDoesNotExist: # HTTP 410 Gone status so that crawlers don't index these expired urls. return HttpResponse(status=410) @@ -162,7 +164,7 @@ def captcha_refresh(request): if not request.is_ajax(): raise Http404 - new_key = CaptchaStore.generate_key() + new_key = storage.generate_key() to_json_response = { 'key': new_key, 'image_url': captcha_image_url(new_key), From 1c1822a6364dc2f53fddbfbf88255b3944d5890a Mon Sep 17 00:00:00 2001 From: michal dub Date: Mon, 18 Jan 2016 23:46:33 +0100 Subject: [PATCH 3/9] added CAPTCHA_STORAGE setting to captcha conf with db as default --- captcha/conf/settings.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/captcha/conf/settings.py b/captcha/conf/settings.py index 569372ab..2b270e0b 100644 --- a/captcha/conf/settings.py +++ b/captcha/conf/settings.py @@ -25,6 +25,9 @@ CAPTCHA_OUTPUT_FORMAT = getattr(settings, 'CAPTCHA_OUTPUT_FORMAT', None) CAPTCHA_TEST_MODE = getattr(settings, 'CAPTCHA_TEST_MODE', getattr(settings, 'CATPCHA_TEST_MODE', False)) +CAPTCHA_STORAGE = getattr(settings, 'CAPTCHA_STORAGE', { + 'BACKEND': 'captcha.storages.db.DBStorage', +}) # Failsafe if CAPTCHA_DICTIONARY_MIN_LENGTH > CAPTCHA_DICTIONARY_MAX_LENGTH: From a20b1ec5734560942e6d4b2c43b27f9431178c3c Mon Sep 17 00:00:00 2001 From: michal dub Date: Mon, 18 Jan 2016 23:47:22 +0100 Subject: [PATCH 4/9] fixed tests to work with storages package --- captcha/tests/tests.py | 32 +++++++++++++++++--------------- testproject/settings.py | 8 ++++++++ 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/captcha/tests/tests.py b/captcha/tests/tests.py index d7292eb3..3afb7417 100644 --- a/captcha/tests/tests.py +++ b/captcha/tests/tests.py @@ -1,7 +1,8 @@ # -*- coding: utf-8 -*- from captcha.conf import settings from captcha.fields import CaptchaField, CaptchaTextInput -from captcha.models import CaptchaStore, get_safe_now +from captcha.helpers import get_safe_now +from captcha.storages import storage from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import reverse from django.test import TestCase, override_settings @@ -42,9 +43,9 @@ def setUp(self): tested_helpers.append('captcha.helpers.huge_words_and_punctuation_challenge') for helper in tested_helpers: challenge, response = settings._callable_from_string(helper)() - self.stores[helper.rsplit('.', 1)[-1].replace('_challenge', '_store')], _ = CaptchaStore.objects.get_or_create(challenge=challenge, response=response) + self.stores[helper.rsplit('.', 1)[-1].replace('_challenge', '_store')] = storage.create(challenge=challenge, response=response) challenge, response = settings.get_challenge()() - self.stores['default_store'], _ = CaptchaStore.objects.get_or_create(challenge=challenge, response=response) + self.stores['default_store'] = storage.create(challenge=challenge, response=response) self.default_store = self.stores['default_store'] def tearDown(self): @@ -54,7 +55,7 @@ def tearDown(self): def __extract_hash_and_response(self, r): hash_ = re.findall(r'value="([0-9a-f]+)"', str(r.content))[0] - response = CaptchaStore.objects.get(hashkey=hash_).response + response = storage.get(hashkey=hash_).response return hash_, response def test_image(self): @@ -108,9 +109,10 @@ def test_wrong_submit(self): self.assertFormError(r, 'form', 'captcha', ugettext_lazy('Invalid CAPTCHA')) def test_deleted_expired(self): - self.default_store.expiration = get_safe_now() - datetime.timedelta(minutes=5) - self.default_store.save() - hash_ = self.default_store.hashkey + challenge, response = settings.get_challenge()() + expiration = get_safe_now() - datetime.timedelta(minutes=5) + expired_store = storage.create(challenge=challenge, response=response, expiration=expiration) + hash_ = expired_store.hashkey r = self.client.post(reverse('captcha-test'), dict(captcha_0=hash_, captcha_1=self.default_store.response, subject='xxx', sender='asasd@asdasd.com')) self.assertEqual(r.status_code, 200) @@ -118,7 +120,7 @@ def test_deleted_expired(self): # expired -> deleted try: - CaptchaStore.objects.get(hashkey=hash_) + storage.get(hashkey=hash_) self.fail() except: pass @@ -134,9 +136,9 @@ def test_custom_error_message(self): self.assertFormError(r, 'form', 'captcha', ugettext_lazy('This field is required.')) def test_repeated_challenge(self): - CaptchaStore.objects.create(challenge='xxx', response='xxx') + storage.create(challenge='xxx', response='xxx') try: - CaptchaStore.objects.create(challenge='xxx', response='xxx') + storage.create(challenge='xxx', response='xxx') except Exception: self.fail() @@ -159,12 +161,12 @@ def test_repeated_challenge_form_submit(self): else: self.fail() try: - store_1 = CaptchaStore.objects.get(hashkey=hash_1) - store_2 = CaptchaStore.objects.get(hashkey=hash_2) + store_1 = storage.get(hashkey=hash_1) + store_2 = storage.get(hashkey=hash_2) except: self.fail() - self.assertTrue(store_1.pk != store_2.pk) + self.assertTrue(store_1.hashkey != store_2.hashkey) self.assertTrue(store_1.response == store_2.response) self.assertTrue(hash_1 != hash_2) @@ -173,7 +175,7 @@ def test_repeated_challenge_form_submit(self): self.assertTrue(str(r1.content).find('Form validated') > 0) try: - store_2 = CaptchaStore.objects.get(hashkey=hash_2) + store_2 = storage.get(hashkey=hash_2) except: self.fail() @@ -302,7 +304,7 @@ def test_expired_captcha_returns_410(self): for key in [store.hashkey for store in six.itervalues(self.stores)]: response = self.client.get(reverse('captcha-image', kwargs=dict(key=key))) self.assertEqual(response.status_code, 200) - CaptchaStore.objects.filter(hashkey=key).delete() + storage.delete(hashkey=key) response = self.client.get(reverse('captcha-image', kwargs=dict(key=key))) self.assertEqual(response.status_code, 410) diff --git a/testproject/settings.py b/testproject/settings.py index dbd69117..ceecb65b 100644 --- a/testproject/settings.py +++ b/testproject/settings.py @@ -17,6 +17,14 @@ } } +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'captcha_test_cache_st', + } +} + + # TEST_DATABASE_CHARSET = "utf8" # TEST_DATABASE_COLLATION = "utf8_general_ci" From c3f9d33374de16cfa57c54356eb1e5040863520e Mon Sep 17 00:00:00 2001 From: michal dub Date: Mon, 18 Jan 2016 23:49:27 +0100 Subject: [PATCH 5/9] updated docs usage to use storage instead of raw CaptchaStore --- docs/usage.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/usage.rst b/docs/usage.rst index 91b9039f..24078d48 100644 --- a/docs/usage.rst +++ b/docs/usage.rst @@ -89,7 +89,7 @@ Example usage for ajax form An example CAPTCHA validation in AJAX:: from django.views.generic.edit import CreateView - from captcha.models import CaptchaStore + from captcha.storages import storage from captcha.helpers import captcha_image_url from django.http import HttpResponse import json @@ -104,7 +104,7 @@ An example CAPTCHA validation in AJAX:: to_json_response['status'] = 0 to_json_response['form_errors'] = form.errors - to_json_response['new_cptch_key'] = CaptchaStore.generate_key() + to_json_response['new_cptch_key'] = storage.generate_key() to_json_response['new_cptch_image'] = captcha_image_url(to_json_response['new_cptch_key']) return HttpResponse(json.dumps(to_json_response), content_type='application/json') @@ -115,7 +115,7 @@ An example CAPTCHA validation in AJAX:: to_json_response = dict() to_json_response['status'] = 1 - to_json_response['new_cptch_key'] = CaptchaStore.generate_key() + to_json_response['new_cptch_key'] = storage.generate_key() to_json_response['new_cptch_image'] = captcha_image_url(to_json_response['new_cptch_key']) return HttpResponse(json.dumps(to_json_response), content_type='application/json') From 6cb229808d4d5c43edc4cd80306e4ae4dcf8cc49 Mon Sep 17 00:00:00 2001 From: michal dub Date: Thu, 21 Jan 2016 16:47:17 +0100 Subject: [PATCH 6/9] get_storage function can take conf dict as arg now --- captcha/storages/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/captcha/storages/__init__.py b/captcha/storages/__init__.py index 044594f4..57a421ed 100644 --- a/captcha/storages/__init__.py +++ b/captcha/storages/__init__.py @@ -8,8 +8,8 @@ class InvalidStorageBackendError(ImproperlyConfigured): pass -def get_storage(): - conf = captcha_settings.CAPTCHA_STORAGE +def get_storage(storage_conf=None): + conf = storage_conf or captcha_settings.CAPTCHA_STORAGE try: backend = conf['BACKEND'] # Trying to import the given backend, in case it's a dotted path From e247a4027351dad2ed1791197003e8b9cb4d53b5 Mon Sep 17 00:00:00 2001 From: michal dub Date: Thu, 21 Jan 2016 16:48:00 +0100 Subject: [PATCH 7/9] test cache storage too --- captcha/tests/tests.py | 65 ++++++++++++++++++++++++++++++++++-------- 1 file changed, 53 insertions(+), 12 deletions(-) diff --git a/captcha/tests/tests.py b/captcha/tests/tests.py index 3afb7417..989ef245 100644 --- a/captcha/tests/tests.py +++ b/captcha/tests/tests.py @@ -1,8 +1,12 @@ # -*- coding: utf-8 -*- from captcha.conf import settings +from captcha import storages +from captcha import views +from captcha import fields +from captcha.management.commands import captcha_clean from captcha.fields import CaptchaField, CaptchaTextInput from captcha.helpers import get_safe_now -from captcha.storages import storage +from captcha.storages import get_storage from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import reverse from django.test import TestCase, override_settings @@ -24,6 +28,37 @@ except ImportError: import Image # NOQA +# store original storage setting +orig_storage = storages.storage + + +class OverrideStorageMixin(object): + """ + Use for test cases with different storage setting + """ + + new_storage = get_storage({ + 'BACKEND': 'captcha.storages.cache.CacheStorage', + }) + + def setUp(self): + + storages.storage = self.new_storage + views.storage = storages.storage + fields.storage = storages.storage + captcha_clean.storage = storages.storage + + super(OverrideStorageMixin, self).setUp() + + def tearDown(self): + + super(OverrideStorageMixin, self).tearDown() + + storages.storage = orig_storage + views.storage = orig_storage + fields.storage = orig_storage + captcha_clean.storage = orig_storage + @override_settings(ROOT_URLCONF='captcha.tests.urls') class CaptchaCase(TestCase): @@ -43,9 +78,9 @@ def setUp(self): tested_helpers.append('captcha.helpers.huge_words_and_punctuation_challenge') for helper in tested_helpers: challenge, response = settings._callable_from_string(helper)() - self.stores[helper.rsplit('.', 1)[-1].replace('_challenge', '_store')] = storage.create(challenge=challenge, response=response) + self.stores[helper.rsplit('.', 1)[-1].replace('_challenge', '_store')] = storages.storage.create(challenge=challenge, response=response) challenge, response = settings.get_challenge()() - self.stores['default_store'] = storage.create(challenge=challenge, response=response) + self.stores['default_store'] = storages.storage.create(challenge=challenge, response=response) self.default_store = self.stores['default_store'] def tearDown(self): @@ -55,7 +90,7 @@ def tearDown(self): def __extract_hash_and_response(self, r): hash_ = re.findall(r'value="([0-9a-f]+)"', str(r.content))[0] - response = storage.get(hashkey=hash_).response + response = storages.storage.get(hashkey=hash_).response return hash_, response def test_image(self): @@ -111,7 +146,7 @@ def test_wrong_submit(self): def test_deleted_expired(self): challenge, response = settings.get_challenge()() expiration = get_safe_now() - datetime.timedelta(minutes=5) - expired_store = storage.create(challenge=challenge, response=response, expiration=expiration) + expired_store = storages.storage.create(challenge=challenge, response=response, expiration=expiration) hash_ = expired_store.hashkey r = self.client.post(reverse('captcha-test'), dict(captcha_0=hash_, captcha_1=self.default_store.response, subject='xxx', sender='asasd@asdasd.com')) @@ -120,7 +155,7 @@ def test_deleted_expired(self): # expired -> deleted try: - storage.get(hashkey=hash_) + storages.storage.get(hashkey=hash_) self.fail() except: pass @@ -136,9 +171,9 @@ def test_custom_error_message(self): self.assertFormError(r, 'form', 'captcha', ugettext_lazy('This field is required.')) def test_repeated_challenge(self): - storage.create(challenge='xxx', response='xxx') + storages.storage.create(challenge='xxx', response='xxx') try: - storage.create(challenge='xxx', response='xxx') + storages.storage.create(challenge='xxx', response='xxx') except Exception: self.fail() @@ -161,8 +196,8 @@ def test_repeated_challenge_form_submit(self): else: self.fail() try: - store_1 = storage.get(hashkey=hash_1) - store_2 = storage.get(hashkey=hash_2) + store_1 = storages.storage.get(hashkey=hash_1) + store_2 = storages.storage.get(hashkey=hash_2) except: self.fail() @@ -175,7 +210,7 @@ def test_repeated_challenge_form_submit(self): self.assertTrue(str(r1.content).find('Form validated') > 0) try: - store_2 = storage.get(hashkey=hash_2) + store_2 = storages.storage.get(hashkey=hash_2) except: self.fail() @@ -304,7 +339,7 @@ def test_expired_captcha_returns_410(self): for key in [store.hashkey for store in six.itervalues(self.stores)]: response = self.client.get(reverse('captcha-image', kwargs=dict(key=key))) self.assertEqual(response.status_code, 200) - storage.delete(hashkey=key) + storages.storage.delete(hashkey=key) response = self.client.get(reverse('captcha-image', kwargs=dict(key=key))) self.assertEqual(response.status_code, 410) @@ -364,5 +399,11 @@ def test_template_overrides(self): settings.CAPTCHA_IMAGE_TEMPLATE = __current_test_mode_setting +class CaptchaCaseCacheStorage(OverrideStorageMixin, CaptchaCase): + + def test_default_store_from_cache(self): + self.assertEqual(self.default_store.pk, None) + + def trivial_challenge(): return 'trivial', 'trivial' From 00c5703e088d035739ca06675de367c5a6fb9ad2 Mon Sep 17 00:00:00 2001 From: michal dub Date: Thu, 21 Jan 2016 17:08:44 +0100 Subject: [PATCH 8/9] fixed unicodes for cache save --- captcha/storages/cache.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/captcha/storages/cache.py b/captcha/storages/cache.py index 8269e0d9..cbb5b13f 100644 --- a/captcha/storages/cache.py +++ b/captcha/storages/cache.py @@ -1,4 +1,5 @@ from django.core.cache import caches +from django.utils.encoding import force_text from ..helpers import get_safe_now from .base import BaseStorage @@ -18,9 +19,9 @@ def get_key(self, hashkey): def create_obj(self, challenge, response, hashkey, expiration): key = self.get_key(hashkey) data = dict( - challenge=challenge, - response=response, - hashkey=hashkey, + challenge=force_text(challenge), + response=force_text(response), + hashkey=force_text(hashkey), expiration=expiration ) self.cache.set(key, data, timeout=self.get_timeout()) From c9289bd1cf0ce9e556225ad52682df160833354c Mon Sep 17 00:00:00 2001 From: michal dub Date: Thu, 21 Jan 2016 17:32:24 +0100 Subject: [PATCH 9/9] added CAPTCHA_STORAGE setting to docs --- docs/advanced.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/advanced.rst b/docs/advanced.rst index 54b69d01..4ee6c92f 100644 --- a/docs/advanced.rst +++ b/docs/advanced.rst @@ -6,6 +6,21 @@ Configuration toggles The following configuration elements can be defined (in your ``settings.py``) +CAPTCHA_STORAGE +---------------- + +Set storage backend for captcha records. Note, you must define ``CAPTCHA_STORAGE`` in your project settings. :: + + CAPTCHA_STORAGE = { + 'BACKEND': 'captcha.storages.cache.CacheStorage', + } + +Default backend is: :: + + CAPTCHA_STORAGE = { + 'BACKEND': 'captcha.storages.db.DBStorage', + } + CAPTCHA_FONT_PATH -----------------