Starting to add new functioning tests, beginning with dependency checker

EmailTemplateFixes
Griffin Caprio 2015-01-06 16:45:49 -06:00
parent b2face2680
commit 7cb9dbbb2e
6 changed files with 354 additions and 278 deletions

View File

@ -22,7 +22,7 @@ The old versions are tagged as follows:
* tag 2.0 - original updated trunk from Seantis version. * tag 2.0 - original updated trunk from Seantis version.
* tag 2.5 - contains the original Seantis version & all PRs merged in as of 12/09/15. It's considered to be the backwards compatible version of the repository. * tag 2.5 - contains the original Seantis version & all PRs merged in as of 12/09/15. It's considered to be the backwards compatible version of the repository.
The new version is the current trunk and is dubbed v3.0. The new version is the current trunk and is dubbed v3.0. v3.0 should be considered a new project & thus will contain backwards incompatible changes. When possible, we'll try and backport fixes to the v2.x branches, but it will not be a priority.
About this Manual About this Manual
----------------- -----------------

View File

@ -0,0 +1,126 @@
from questionnaire.models import Question, Answer
import logging
def check_actual_answers_against_expression(check_answer, actual_answer, check_question):
# Numeric Value Expressions
if check_answer[0:1] in "<>":
try:
actual_answer = float(actual_answer)
if check_answer[1:2] == "=":
check_value = float(check_answer[2:])
else:
check_value = float(check_answer[1:])
except:
logging.error("ERROR: must use numeric values with < <= => > checks (%r)" % check_question)
return False
if check_answer.startswith("<="):
return actual_answer <= check_value
if check_answer.startswith(">="):
return actual_answer >= check_value
if check_answer.startswith("<"):
return actual_answer < check_value
if check_answer.startswith(">"):
return actual_answer > check_value
# Convert answer to list if not already one
if type(actual_answer) != type(list()):
actual_answer = [actual_answer]
# Negative Value Expressions
if check_answer.startswith("!"):
for actual_answer in actual_answer:
if actual_answer == '':
return False
if check_answer[1:].strip() == actual_answer.strip():
return False
return True
return
# Positive Value Expressions
for actual_answer in actual_answer:
if check_answer.strip() == actual_answer.strip():
return True
return False
def dep_check(expr, runinfo, answerdict):
"""
Given a comma separated question number and expression, determine if the
provided answer to the question number satisfies the expression.
If the expression starts with >, >=, <, or <=, compare the rest of
the expression numerically and return False if it's not able to be
converted to an integer.
If the expression starts with !, return true if the rest of the expression
does not match the answer.
Otherwise return true if the expression matches the answer.
If there is no comma and only a question number, it checks if the answer
is "yes"
When looking up the answer, it first checks if it's in the answerdict,
then it checks runinfo's cookies, then it does a database lookup to find
the answer.
The use of the comma separator is purely historical.
"""
if hasattr(runinfo, 'questionset'):
questionnaire = runinfo.questionset.questionnaire
elif hasattr(runinfo, 'questionnaire'):
questionnaire = runinfo.questionnaire
else:
assert False
# Parse expression
if "," not in expr:
expr = expr + ",yes"
check_questionnum, check_answer = expr.split(",", 1)
# Get question to check
try:
check_question = Question.objects.get(number=check_questionnum,
questionset__questionnaire=questionnaire)
except Question.DoesNotExist:
return False
# Parse & load actual answer(s) from user
if check_question in answerdict:
# test for membership in multiple choice questions
# FIXME: only checking answerdict
for k, v in answerdict[check_question].items():
if not k.startswith('multiple_'):
continue
if check_answer.startswith("!"):
if check_answer[1:].strip() == v.strip():
return False
elif check_answer.strip() == v.strip():
return True
actual_answer = answerdict[check_question].get('ANSWER', '')
elif hasattr(runinfo, 'get_cookie') and runinfo.get_cookie(check_questionnum, False):
actual_answer = runinfo.get_cookie(check_questionnum)
else:
# retrieve from database
ansobj = Answer.objects.filter(question=check_question,
runid=runinfo.runid, subject=runinfo.subject)
if ansobj:
actual_answer = ansobj[0].split_answer()[0]
logging.warn("Put `store` in checks field for question %s" % check_questionnum)
else:
actual_answer = None
if not actual_answer:
if check_question.getcheckdict():
actual_answer = check_question.getcheckdict().get('default')
if actual_answer is None:
actual_answer = u''
if type(actual_answer) == type(list()):
actual_answer = actual_answer[0]
return check_actual_answers_against_expression(check_answer, actual_answer, check_question)

View File

@ -0,0 +1,191 @@
"""
Basic Test Suite for Questionnaire Application
Unfortunately Django 1.0 only has TestCase and not TransactionTestCase
so we can't test that a submitted page with an error does not have any
answers submitted to the DB.
"""
from django.test import TestCase
from django.test.client import Client
from questionnaire.models import *
from datetime import datetime
import os
class TypeTest(TestCase):
fixtures = ( 'testQuestions.yaml', )
urls = 'questionnaire.test_urls'
def setUp(self):
self.ansdict1 = {
'questionset_id' : '1',
'question_1' : 'Open Answer 1',
'question_2' : 'Open Answer 2\r\nMultiline',
'question_3' : 'yes',
'question_4' : 'dontknow',
'question_5' : 'yes',
'question_5_comment' : 'this comment is required because of required-yes check',
'question_6' : 'no',
'question_6_comment' : 'this comment is required because of required-no check',
'question_7' : '5',
'question_8_unit' : 'week',
'question_8' : '2',
}
self.ansdict2 = {
'questionset_id' : '2',
'question_9' : 'q9_choice1', # choice
'question_10' : '_entry_', # choice-freeform
'question_10_comment' : 'my freeform',
'question_11_multiple_2' : 'q11_choice2', # choice-multiple
'question_11_multiple_4' : 'q11_choice4', # choice-multiple
'question_12_multiple_1' : 'q12_choice1',# choice-multiple-freeform
'question_12_more_1' : 'blah', # choice-multiple-freeform
}
runinfo = self.runinfo = RunInfo.objects.get(runid='test:test')
self.runid = runinfo.runid
self.subject_id = runinfo.subject_id
def test010_redirect(self):
"Check redirection from generic questionnaire to questionset"
response = self.client.get('/q/test:test/')
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/')
def test020_get_questionset_1(self):
"Get first page of Questions"
response = self.client.get('/q/test:test/1/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
def test030_language_setting(self):
"Set the language and confirm it is set in DB"
response = self.client.get('/q/test:test/1/', {"lang" : "en"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/')
response = self.client.get('/q/test:test/1/')
assert "Don't Know" in response.content
self.assertEqual(response.status_code, 200)
runinfo = RunInfo.objects.get(runid='test:test')
self.assertEqual(runinfo.subject.language, 'en')
response = self.client.get('/q/test:test/1/', {"lang" : "de"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/')
response = self.client.get('/q/test:test/1/')
assert "Weiss nicht" in response.content
self.assertEqual(response.status_code, 200)
runinfo = RunInfo.objects.get(runid='test:test')
self.assertEqual(runinfo.subject.language, 'de')
def test040_missing_question(self):
"Post questions with a mandatory field missing"
c = self.client
ansdict = self.ansdict1.copy()
del ansdict['question_3']
response = c.post('/q/test:test/1/', ansdict)
self.assertEqual(response.status_code, 200)
errors = response.context[-1]['errors']
self.assertEqual(len(errors), 1) and errors.has_key('3')
def test050_missing_question(self):
"Post questions with a mandatory field missing"
c = self.client
ansdict = self.ansdict1.copy()
del ansdict['question_5_comment']
# first set language to english
response = self.client.get('/q/test:test/1/', {"lang" : "en"})
response = c.post('/q/test:test/1/', ansdict)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context[-1]['errors']), 1)
def test060_successful_questionnaire(self):
"POST complete answers for QuestionSet 1"
c = self.client
ansdict1 = self.ansdict1
runinfo = RunInfo.objects.get(runid='test:test')
runid = runinfo.random = runinfo.runid = '1real'
runinfo.save()
response = c.get('/q/1real/1/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
response = c.post('/q/1real/', ansdict1)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/q/1real/2/')
"POST complete answers for QuestionSet 2"
c = self.client
ansdict2 = self.ansdict2
response = c.get('/q/1real/2/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
response = c.post('/q/1real/', ansdict2)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/')
self.assertEqual(RunInfo.objects.filter(runid='1real').count(), 0)
# TODO: The format of these answers seems very strange to me. It was
# simpler before I changed it to get the test to work.
# I'll have to revisit this once I figure out how this is meant to work
# for now it is more important to me that all tests pass
dbvalues = {
'1' : u'["%s"]' % ansdict1['question_1'],
'2' : u'["%s"]' % ansdict1['question_2'],
'3' : u'["%s"]' % ansdict1['question_3'],
'4' : u'["%s"]' % ansdict1['question_4'],
'5' : u'["%s", ["%s"]]' % (ansdict1['question_5'], ansdict1['question_5_comment']),
'6' : u'["%s", ["%s"]]' % (ansdict1['question_6'], ansdict1['question_6_comment']),
'7' : u'[%s]' % ansdict1['question_7'],
'8' : u'%s; %s' % (ansdict1['question_8'], ansdict1['question_8_unit']),
'9' : u'["q9_choice1"]',
'10' : u'[["my freeform"]]',
'11' : u'["q11_choice2", "q11_choice4"]',
'12' : u'["q12_choice1", ["blah"]]',
}
for k, v in dbvalues.items():
ans = Answer.objects.get(runid=runid, subject__id=self.subject_id,
question__number=k)
v = v.replace('\r', '\\r').replace('\n', '\\n')
self.assertEqual(ans.answer, v)
def test070_tags(self):
c = self.client
# the first questionset in questionnaire 2 is always shown,
# but one of its 2 questions is tagged with testtag
with_tags = c.get('/q/test:withtags/1/')
# so we'll get two questions shown if the run is tagged
self.assertEqual(with_tags.status_code, 200)
self.assertEqual(len(with_tags.context['qlist']), 2)
# one question, if the run is not tagged
without_tags = c.get('/q/test:withouttags/1/')
self.assertEqual(without_tags.status_code, 200)
self.assertEqual(len(without_tags.context['qlist']), 1)
# the second questionset is only shown if the run is tagged
with_tags = c.get('/q/test:withtags/2/')
self.assertEqual(with_tags.status_code, 200)
self.assertEqual(len(with_tags.context['qlist']), 1)
# meaning it'll be skipped on the untagged run
without_tags = c.get('/q/test.withouttags/2/')
self.assertEqual(without_tags.status_code, 302) # redirect
# the progress values of the first questionset should reflect
# the fact that in one run there's only one questionset
with_tags = c.get('/q/test:withtags/1/')
without_tags = c.get('/q/test:withouttags/1/')
self.assertEqual(with_tags.context['progress'], 50)
self.assertEqual(without_tags.context['progress'], 100)

View File

@ -1,191 +1,47 @@
"""
Basic Test Suite for Questionnaire Application
Unfortunately Django 1.0 only has TestCase and not TransactionTestCase
so we can't test that a submitted page with an error does not have any
answers submitted to the DB.
"""
from django.test import TestCase from django.test import TestCase
from django.test.client import Client
from questionnaire.models import *
from datetime import datetime
import os
class TypeTest(TestCase): from dependency_checker import check_actual_answers_against_expression
fixtures = ( 'testQuestions.yaml', ) from .models import Question
urls = 'questionnaire.test_urls'
def setUp(self):
self.ansdict1 = {
'questionset_id' : '1',
'question_1' : 'Open Answer 1',
'question_2' : 'Open Answer 2\r\nMultiline',
'question_3' : 'yes',
'question_4' : 'dontknow',
'question_5' : 'yes',
'question_5_comment' : 'this comment is required because of required-yes check',
'question_6' : 'no',
'question_6_comment' : 'this comment is required because of required-no check',
'question_7' : '5',
'question_8_unit' : 'week',
'question_8' : '2',
}
self.ansdict2 = {
'questionset_id' : '2',
'question_9' : 'q9_choice1', # choice
'question_10' : '_entry_', # choice-freeform
'question_10_comment' : 'my freeform',
'question_11_multiple_2' : 'q11_choice2', # choice-multiple
'question_11_multiple_4' : 'q11_choice4', # choice-multiple
'question_12_multiple_1' : 'q12_choice1',# choice-multiple-freeform
'question_12_more_1' : 'blah', # choice-multiple-freeform
}
runinfo = self.runinfo = RunInfo.objects.get(runid='test:test')
self.runid = runinfo.runid
self.subject_id = runinfo.subject_id
def test010_redirect(self): class QuestionSetTests(TestCase):
"Check redirection from generic questionnaire to questionset" def test_dependencies_for_multiple_choice_question(self):
response = self.client.get('/q/test:test/') check_question = Question()
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/') self.assertTrue(check_actual_answers_against_expression('3B', ['3B', '3E'], check_question))
def test_dependencies_for_multiple_choice_question_false(self):
check_question = Question()
self.assertFalse(check_actual_answers_against_expression('3C', ['3B', '3E'], check_question))
def test020_get_questionset_1(self): def test_dependencies_for_multiple_choice_question_negation(self):
"Get first page of Questions" check_question = Question()
response = self.client.get('/q/test:test/1/') self.assertTrue(check_actual_answers_against_expression('!3C', ['3B', '3E'], check_question))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
def test_dependencies_for_multiple_choice_question_negation_false(self):
check_question = Question()
self.assertFalse(check_actual_answers_against_expression('!3C', ['3C', '3E'], check_question))
def test030_language_setting(self): def test_dependencies_for_single_choice_question(self):
"Set the language and confirm it is set in DB" check_question = Question()
response = self.client.get('/q/test:test/1/', {"lang" : "en"}) self.assertTrue(check_actual_answers_against_expression('3B', '3B', check_question))
self.assertEqual(response.status_code, 302) self.assertFalse(check_actual_answers_against_expression('3C', '3B', check_question))
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/') self.assertTrue(check_actual_answers_against_expression('!3C', '3B', check_question))
response = self.client.get('/q/test:test/1/') self.assertTrue(check_actual_answers_against_expression('!3C', '', check_question))
assert "Don't Know" in response.content
self.assertEqual(response.status_code, 200)
runinfo = RunInfo.objects.get(runid='test:test')
self.assertEqual(runinfo.subject.language, 'en')
response = self.client.get('/q/test:test/1/', {"lang" : "de"})
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/q/test:test/1/')
response = self.client.get('/q/test:test/1/')
assert "Weiss nicht" in response.content
self.assertEqual(response.status_code, 200)
runinfo = RunInfo.objects.get(runid='test:test')
self.assertEqual(runinfo.subject.language, 'de')
def test_dependencies_for_numeric_checks(self):
check_question = Question()
self.assertTrue(check_actual_answers_against_expression('>5.6', '6', check_question))
self.assertFalse(check_actual_answers_against_expression('>5.6', '3.6', check_question))
self.assertFalse(check_actual_answers_against_expression('>5.6', '5.6', check_question))
def test040_missing_question(self): self.assertTrue(check_actual_answers_against_expression('>=5.6', '6', check_question))
"Post questions with a mandatory field missing" self.assertFalse(check_actual_answers_against_expression('>=5.6', '3.6', check_question))
c = self.client self.assertTrue(check_actual_answers_against_expression('>=5.6', '5.6', check_question))
ansdict = self.ansdict1.copy()
del ansdict['question_3']
response = c.post('/q/test:test/1/', ansdict)
self.assertEqual(response.status_code, 200)
errors = response.context[-1]['errors']
self.assertEqual(len(errors), 1) and errors.has_key('3')
self.assertTrue(check_actual_answers_against_expression('<5.6', '4.6', check_question))
self.assertFalse(check_actual_answers_against_expression('<5.6', '8.6', check_question))
self.assertFalse(check_actual_answers_against_expression('<5.6', '5.6', check_question))
def test050_missing_question(self): self.assertTrue(check_actual_answers_against_expression('<=5.6', '3.6', check_question))
"Post questions with a mandatory field missing" self.assertFalse(check_actual_answers_against_expression('<=5.6', '9.6', check_question))
c = self.client self.assertTrue(check_actual_answers_against_expression('<=5.6', '5.6', check_question))
ansdict = self.ansdict1.copy()
del ansdict['question_5_comment']
# first set language to english
response = self.client.get('/q/test:test/1/', {"lang" : "en"})
response = c.post('/q/test:test/1/', ansdict)
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.context[-1]['errors']), 1)
def test060_successful_questionnaire(self):
"POST complete answers for QuestionSet 1"
c = self.client
ansdict1 = self.ansdict1
runinfo = RunInfo.objects.get(runid='test:test')
runid = runinfo.random = runinfo.runid = '1real'
runinfo.save()
response = c.get('/q/1real/1/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
response = c.post('/q/1real/', ansdict1)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/q/1real/2/')
"POST complete answers for QuestionSet 2"
c = self.client
ansdict2 = self.ansdict2
response = c.get('/q/1real/2/')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.template[0].name, 'questionnaire/questionset.html')
response = c.post('/q/1real/', ansdict2)
self.assertEqual(response.status_code, 302)
self.assertEqual(response['Location'], 'http://testserver/')
self.assertEqual(RunInfo.objects.filter(runid='1real').count(), 0)
# TODO: The format of these answers seems very strange to me. It was
# simpler before I changed it to get the test to work.
# I'll have to revisit this once I figure out how this is meant to work
# for now it is more important to me that all tests pass
dbvalues = {
'1' : u'["%s"]' % ansdict1['question_1'],
'2' : u'["%s"]' % ansdict1['question_2'],
'3' : u'["%s"]' % ansdict1['question_3'],
'4' : u'["%s"]' % ansdict1['question_4'],
'5' : u'["%s", ["%s"]]' % (ansdict1['question_5'], ansdict1['question_5_comment']),
'6' : u'["%s", ["%s"]]' % (ansdict1['question_6'], ansdict1['question_6_comment']),
'7' : u'[%s]' % ansdict1['question_7'],
'8' : u'%s; %s' % (ansdict1['question_8'], ansdict1['question_8_unit']),
'9' : u'["q9_choice1"]',
'10' : u'[["my freeform"]]',
'11' : u'["q11_choice2", "q11_choice4"]',
'12' : u'["q12_choice1", ["blah"]]',
}
for k, v in dbvalues.items():
ans = Answer.objects.get(runid=runid, subject__id=self.subject_id,
question__number=k)
v = v.replace('\r', '\\r').replace('\n', '\\n')
self.assertEqual(ans.answer, v)
def test070_tags(self):
c = self.client
# the first questionset in questionnaire 2 is always shown,
# but one of its 2 questions is tagged with testtag
with_tags = c.get('/q/test:withtags/1/')
# so we'll get two questions shown if the run is tagged
self.assertEqual(with_tags.status_code, 200)
self.assertEqual(len(with_tags.context['qlist']), 2)
# one question, if the run is not tagged
without_tags = c.get('/q/test:withouttags/1/')
self.assertEqual(without_tags.status_code, 200)
self.assertEqual(len(without_tags.context['qlist']), 1)
# the second questionset is only shown if the run is tagged
with_tags = c.get('/q/test:withtags/2/')
self.assertEqual(with_tags.status_code, 200)
self.assertEqual(len(with_tags.context['qlist']), 1)
# meaning it'll be skipped on the untagged run
without_tags = c.get('/q/test.withouttags/2/')
self.assertEqual(without_tags.status_code, 302) # redirect
# the progress values of the first questionset should reflect
# the fact that in one run there's only one questionset
with_tags = c.get('/q/test:withtags/1/')
without_tags = c.get('/q/test:withouttags/1/')
self.assertEqual(with_tags.context['progress'], 50)
self.assertEqual(without_tags.context['progress'], 100)

View File

@ -20,6 +20,7 @@ from questionnaire.parsers import *
from questionnaire.emails import _send_email, send_emails from questionnaire.emails import _send_email, send_emails
from questionnaire.utils import numal_sort, split_numal from questionnaire.utils import numal_sort, split_numal
from questionnaire.request_cache import request_cache from questionnaire.request_cache import request_cache
from questionnaire.dependency_checker import dep_check
from questionnaire import profiler from questionnaire import profiler
import logging import logging
import random import random
@ -915,104 +916,6 @@ def has_tag(tag, runinfo):
return tag in (t.strip() for t in runinfo.tags.split(',')) return tag in (t.strip() for t in runinfo.tags.split(','))
def dep_check(expr, runinfo, answerdict):
"""
Given a comma separated question number and expression, determine if the
provided answer to the question number satisfies the expression.
If the expression starts with >, >=, <, or <=, compare the rest of
the expression numerically and return False if it's not able to be
converted to an integer.
If the expression starts with !, return true if the rest of the expression
does not match the answer.
Otherwise return true if the expression matches the answer.
If there is no comma and only a question number, it checks if the answer
is "yes"
When looking up the answer, it first checks if it's in the answerdict,
then it checks runinfo's cookies, then it does a database lookup to find
the answer.
The use of the comma separator is purely historical.
"""
if hasattr(runinfo, 'questionset'):
questionnaire = runinfo.questionset.questionnaire
elif hasattr(runinfo, 'questionnaire'):
questionnaire = runinfo.questionnaire
else:
assert False
if "," not in expr:
expr = expr + ",yes"
check_questionnum, check_answer = expr.split(",", 1)
try:
check_question = Question.objects.get(number=check_questionnum,
questionset__questionnaire=questionnaire)
except Question.DoesNotExist:
return False
if check_question in answerdict:
# test for membership in multiple choice questions
# FIXME: only checking answerdict
for k, v in answerdict[check_question].items():
if not k.startswith('multiple_'):
continue
if check_answer.startswith("!"):
if check_answer[1:].strip() == v.strip():
return False
elif check_answer.strip() == v.strip():
return True
actual_answer = answerdict[check_question].get('ANSWER', '')
elif hasattr(runinfo, 'get_cookie') and runinfo.get_cookie(check_questionnum, False):
actual_answer = runinfo.get_cookie(check_questionnum)
else:
# retrieve from database
ansobj = Answer.objects.filter(question=check_question,
runid=runinfo.runid, subject=runinfo.subject)
if ansobj:
actual_answer = ansobj[0].split_answer()[0]
logging.warn("Put `store` in checks field for question %s" % check_questionnum)
else:
actual_answer = None
if not actual_answer:
if check_question.getcheckdict():
actual_answer = check_question.getcheckdict().get('default')
if actual_answer is None:
actual_answer = u''
if type(actual_answer) == type(list()):
actual_answer = actual_answer[0]
if check_answer[0:1] in "<>":
try:
actual_answer = float(actual_answer)
if check_answer[1:2] == "=":
check_value = float(check_answer[2:])
else:
check_value = float(check_answer[1:])
except:
logging.error("ERROR: must use numeric values with < <= => > checks (%r)" % check_question)
return False
if check_answer.startswith("<="):
return actual_answer <= check_value
if check_answer.startswith(">="):
return actual_answer >= check_value
if check_answer.startswith("<"):
return actual_answer < check_value
if check_answer.startswith(">"):
return actual_answer > check_value
if check_answer.startswith("!"):
if actual_answer == '':
return False
return check_answer[1:].strip() != actual_answer.strip()
return check_answer.strip() == actual_answer.strip()
@permission_required("questionnaire.management") @permission_required("questionnaire.management")
def send_email(request, runinfo_id): def send_email(request, runinfo_id):
if request.method != "POST": if request.method != "POST":