From de18189a9db99b45a8036a6a338a86e474d7dcc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olivier=20Larchev=C3=AAque?= Date: Thu, 8 Aug 2013 18:15:37 -0400 Subject: [PATCH] start package --- .gitignore | 1 + MANIFEST.in | 1 + secretquestions/conf.py | 6 ++ secretquestions/decorators.py | 38 ++++++++ secretquestions/forms.py | 24 ++++- secretquestions/models.py | 7 +- secretquestions/templates/base.html | 6 ++ .../templates/secretquestions/step.html | 24 +++++ secretquestions/tests/__init__.py | 1 + secretquestions/tests/configuration.py | 96 +++++++++++++++++++ secretquestions/tests/settings.py | 20 ++++ secretquestions/tests/urls.py | 11 +++ secretquestions/urls.py | 6 +- secretquestions/views.py | 66 ++++++++++++- tox.ini | 19 ++++ 15 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 MANIFEST.in create mode 100644 secretquestions/conf.py create mode 100644 secretquestions/decorators.py create mode 100644 secretquestions/templates/base.html create mode 100644 secretquestions/templates/secretquestions/step.html create mode 100644 secretquestions/tests/__init__.py create mode 100644 secretquestions/tests/configuration.py create mode 100644 secretquestions/tests/settings.py create mode 100644 secretquestions/tests/urls.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore index 3ab10f0..95a52ec 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ *egg-info *.pyc +.tox diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..943440d --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +recursive-include secretquestions/templates * diff --git a/secretquestions/conf.py b/secretquestions/conf.py new file mode 100644 index 0000000..289e263 --- /dev/null +++ b/secretquestions/conf.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from django.conf import settings + +SQ_SESSION_KEY = getattr(settings, 'SQ_SESSION_KEY', 'sq_token') +SQ_TOKEN_TTL = getattr(settings, 'SQ_TOKEN_TTL', 60*3) diff --git a/secretquestions/decorators.py b/secretquestions/decorators.py new file mode 100644 index 0000000..6c5d84e --- /dev/null +++ b/secretquestions/decorators.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +from datetime import timedelta, datetime +import re + +from django.shortcuts import redirect +from django.utils.translation import ugettext as _ + +from django.contrib import messages + +from .views import SecretQuestionWizard +from .conf import SQ_SESSION_KEY, SQ_TOKEN_TTL + +def secret_questions_required(view, ttl=SQ_TOKEN_TTL): + def _wrapped(request, *args, **kwargs): + session_token, url, date = request.session.get(SQ_SESSION_KEY, + (None, None, datetime.now())) + get_token = request.GET.get(SQ_SESSION_KEY, None) + date_max = date + timedelta(seconds=ttl) + + if session_token is None or get_token is None: + wiz = SecretQuestionWizard(request) + return wiz(request, *args, **kwargs) + + if date_max < datetime.now() or \ + not request.get_full_path().startswith(url): + if request.method == "POST": + messages.error(request, _("Your modifications were canceled.")) + url = request.get_full_path() + clean_url = re.sub("(.*)%s=[a..z0..9]*(.*)" % SQ_SESSION_KEY, "\\1", url) + return redirect(clean_url) + + if session_token == get_token: + return view(request, *args, **kwargs) + + raise Exception('SQ') + + return _wrapped diff --git a/secretquestions/forms.py b/secretquestions/forms.py index 2a13145..2fa7c6e 100644 --- a/secretquestions/forms.py +++ b/secretquestions/forms.py @@ -5,7 +5,9 @@ from django import forms from django.forms.models import modelformset_factory, ModelForm from django.utils.translation import ugettext as _ -from .models import Answer, crypt_answer +from django.contrib.auth.models import User + +from .models import Answer, crypt_answer, check_answer MAX_SECRET_QUESTIONS = getattr(settings, 'MAX_SECRET_QUESTIONS', 3) @@ -62,3 +64,23 @@ class AnswerFormSet(_FreeAnswerFormSet): questions.append(question) return super(AnswerFormSet, self).clean() + + +class UsernameForm(forms.Form): + username = forms.CharField() + + def clean_username(self): + data = self.cleaned_data['username'] + try: + return User.objects.get(username=data) + except User.DoesNotExist: + raise forms.ValidationError(_("Username not found")) + + +class QuestionForm(forms.Form): + raw_answer = forms.CharField() + + def clean_raw_answer(self): + data = self.cleaned_data['raw_answer'] + if not check_answer(data, self.answer.secret): + raise forms.ValidationError(_("This answer is incorrect.")) diff --git a/secretquestions/models.py b/secretquestions/models.py index f567ed4..a373b4d 100644 --- a/secretquestions/models.py +++ b/secretquestions/models.py @@ -2,7 +2,7 @@ from django.db import models -from django.contrib.auth.models import get_hexdigest +from django.contrib.auth.models import get_hexdigest, check_password def crypt_answer(raw): @@ -13,6 +13,9 @@ def crypt_answer(raw): return '%s$%s$%s' % (algo, salt, hsh) +def check_answer(raw, crypted): + return check_password(raw, crypted) + class Question(models.Model): text = models.CharField(max_length=255) @@ -21,7 +24,7 @@ class Question(models.Model): class Answer(models.Model): - user = models.ForeignKey('auth.User') + user = models.ForeignKey('auth.User', related_name="secret_answers") question = models.ForeignKey('secretquestions.Question') secret = models.CharField(max_length=255) diff --git a/secretquestions/templates/base.html b/secretquestions/templates/base.html new file mode 100644 index 0000000..e5ba34e --- /dev/null +++ b/secretquestions/templates/base.html @@ -0,0 +1,6 @@ + + + {% block content %} + {% endblock content %} + + diff --git a/secretquestions/templates/secretquestions/step.html b/secretquestions/templates/secretquestions/step.html new file mode 100644 index 0000000..69a8dd0 --- /dev/null +++ b/secretquestions/templates/secretquestions/step.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} + +{% if form.user %} +

{% trans "Step" %} {{ step }}/{{ step_count }}

+{% endif %} + +

+{% if form.answer %} + {{ form.answer.question }} + {% endif %} +

+ +
{% csrf_token %} + + {{ form }} +
+ + {{ previous_fields|safe }} + +
+{% endblock %} diff --git a/secretquestions/tests/__init__.py b/secretquestions/tests/__init__.py new file mode 100644 index 0000000..c48b029 --- /dev/null +++ b/secretquestions/tests/__init__.py @@ -0,0 +1 @@ +from configuration import ConfigurationTest diff --git a/secretquestions/tests/configuration.py b/secretquestions/tests/configuration.py new file mode 100644 index 0000000..8e42c09 --- /dev/null +++ b/secretquestions/tests/configuration.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- + +from django.test import TestCase + +from django.core.urlresolvers import reverse +from django.test.client import Client +from django.conf import settings + +from django.contrib.auth.models import User + +from secretquestions.models import Question, Answer + + +class ConfigurationTest(TestCase): + + client = Client() + username = 'paul' + password = 'lemay' + + def setUp(self): + self.create_user() + self.create_questions() + + def create_user(self): + self.user = User.objects.create(username=self.username) + self.user.set_password(self.password) + self.user.save() + + def create_questions(self): + self.question1 = Question.objects.create(text="question1") + self.question2 = Question.objects.create(text="question2") + self.question3 = Question.objects.create(text="question3") + self.questions = (self.question1, self.question2, self.question3) + + def test_access_setup_questions_for_anonymous(self): + url = reverse('sq_setup') + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertEqual('Location' in response, True) + self.assertEqual(settings.LOGIN_URL in response['Location'], True) + + def test_access_setup_questions_for_authenticated(self): + self.assertEqual(self.client.login(username=self.username, + password=self.password), True) + + url = reverse('sq_setup') + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_setting_answer_for_one_question(self): + raw_password = 'xxx' + self.assertEqual(self.client.login(username=self.username, + password=self.password), True) + url = reverse('sq_setup') + + data = { + 'form-TOTAL_FORMS': u'1', + 'form-INITIAL_FORMS': u'0', + 'form-MAX_NUM_FORMS': u'', + 'form-0-question': self.question1.id, + 'form-0-secret': raw_password, + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + answer = Answer.objects.get(user=self.user, question=self.question1) + self.assertNotEqual(answer.secret, raw_password) + self.assertNotEqual(answer.secret, '') + + + def test_setting_empty_answer_for_one_question(self): + raw_password = '' + self.assertEqual(self.client.login(username=self.username, + password=self.password), True) + url = reverse('sq_setup') + + data = { + 'form-TOTAL_FORMS': u'1', + 'form-INITIAL_FORMS': u'0', + 'form-MAX_NUM_FORMS': u'', + 'form-0-question': self.question1.id, + 'form-0-secret': raw_password, + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 200) + with self.assertRaises(Answer.DoesNotExist): + Answer.objects.get(user=self.user, question=self.question1) + + def test_check_reset(self): + raw_password = 'xxx' + self.test_setting_answer_for_one_question() + url = reverse('sq_setup') + response = self.client.get(url) + self.assertFalse(raw_password in response.content) + answer = Answer.objects.get(user=self.user, question=self.question1) + self.assertFalse(answer.secret in response.content) + diff --git a/secretquestions/tests/settings.py b/secretquestions/tests/settings.py new file mode 100644 index 0000000..30910d4 --- /dev/null +++ b/secretquestions/tests/settings.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +SECRET_KEY = 'secret' + +ROOT_URLCONF = 'secretquestions.tests.urls' + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', + } + } + +INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.admin', + 'registration', + 'secretquestions',) diff --git a/secretquestions/tests/urls.py b/secretquestions/tests/urls.py new file mode 100644 index 0000000..12e9f41 --- /dev/null +++ b/secretquestions/tests/urls.py @@ -0,0 +1,11 @@ +from django.conf.urls.defaults import patterns, include + +from django.contrib import admin + +admin.autodiscover() + +urlpatterns = patterns('', + (r'^admin/(.*)', include(admin.site.urls)), + (r'^accounts/', include('registration.urls')), + (r'^secret/', include('secretquestions.urls')), +) diff --git a/secretquestions/urls.py b/secretquestions/urls.py index 1dbcc54..eecfc63 100644 --- a/secretquestions/urls.py +++ b/secretquestions/urls.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- -from django.conf.urls.defaults import patterns +from django.conf.urls.defaults import patterns, url urlpatterns = patterns('', - (r'questions/setup$', 'secretquestions.views.setup_form'), - (r'questions/ask$','secretquestions.views.ask_form'), + url(r'questions/setup$', 'secretquestions.views.setup_form', + name="sq_setup"), ) diff --git a/secretquestions/views.py b/secretquestions/views.py index 386db09..5d3784a 100644 --- a/secretquestions/views.py +++ b/secretquestions/views.py @@ -1,15 +1,24 @@ # -*- coding: utf-8 -*- +from datetime import datetime +from urlparse import urlparse, parse_qs +from urllib import urlencode + +from django.core.exceptions import ObjectDoesNotExist from django.template import RequestContext from django.shortcuts import render_to_response, redirect from django.conf import settings from django.utils.translation import ugettext as _ from django.contrib.auth.decorators import login_required +from django.contrib.auth.models import User from django.contrib import messages +from django.contrib.formtools.wizard import FormWizard -from .forms import AnswerFormSet +from django.middleware.csrf import _get_new_csrf_key +from .forms import AnswerFormSet, UsernameForm, QuestionForm +from .conf import SQ_SESSION_KEY @login_required def setup_form(request): @@ -27,5 +36,56 @@ def setup_form(request): context_instance=RequestContext(request)) -def ask_form(request): - pass +class SecretQuestionWizard(FormWizard): + __name__ = 'SecretQuestionWizard' # fix for debugtoolbar introspection + + def __init__(self, request): + self.user = None + self.redirect = request.get_full_path() + + self.step_mapping = {} + + if request.user.is_authenticated(): + form_list = [] + else: + if request.POST: + username = request.POST.get('0-username') + try: + self.user = User.objects.get(username=username) + except ObjectDoesNotExist: + pass + form_list = [UsernameForm, ] + + if self.user: + for answer in self.user.secret_answers.all(): + self.step_mapping[len(form_list)] = answer + form_list.append(QuestionForm) + + super(SecretQuestionWizard, self).__init__(form_list) + + def get_form(self, step, data=None): + answer = self.step_mapping.get(step, None) + form = super(SecretQuestionWizard, self).get_form(step, data) + form.user = self.user + form.answer = answer + return form + + def get_template(self, step): + return 'secretquestions/step.html' + + def done(self, request, form_list): + + for form in form_list: + if not form.is_valid(): + return self.redirect + + token = _get_new_csrf_key() + path = urlparse(self.redirect).path + params = parse_qs(urlparse(self.redirect).query, keep_blank_values=True) + params[SQ_SESSION_KEY] = token + qs = urlencode(params) + url = "%s?%s" % (path, qs) + + request.session[SQ_SESSION_KEY] = (token, path, datetime.now()) + + return redirect(url) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..d4862e7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,19 @@ +[tox] +envlist = django1.3, django1.4 + +[testenv] +deps = + django-registration==0.8 + +commands = + django-admin.py test secretquestions --settings=secretquestions.tests.settings + +[testenv:django1.3] +deps = + {[testenv]deps} + django==1.3 + +[testenv:django1.4] +deps = + {[testenv]deps} + django==1.4