From e3ee50530e04af2156fd5712f5b2c3332281bb28 Mon Sep 17 00:00:00 2001 From: "Julio C. Menendez" Date: Sun, 14 Sep 2014 08:51:29 -0600 Subject: [PATCH 1/3] Allows keeping track of current questionnaire and questionnaire set with sessions instead of url args. --- questionnaire/urls.py | 31 +++++++--- questionnaire/views.py | 131 ++++++++++++++++++++++++++--------------- 2 files changed, 107 insertions(+), 55 deletions(-) diff --git a/questionnaire/urls.py b/questionnaire/urls.py index fec4dd7..572e89b 100644 --- a/questionnaire/urls.py +++ b/questionnaire/urls.py @@ -3,15 +3,28 @@ from django.conf.urls import * from views import * -urlpatterns = patterns('', + +urlpatterns = patterns( + '', url(r'^$', - questionnaire, name='questionnaire_noargs'), + questionnaire, name='questionnaire_noargs'), url(r'^csv/(?P\d+)/$', - export_csv, name='export_csv'), - url(r'^(?P[^/]+)/progress/$', - get_async_progress, name='progress'), - url(r'^(?P[^/]+)/(?P[-]{0,1}\d+)/$', - questionnaire, name='questionset'), - url(r'^(?P[^/]+)/$', - questionnaire, name='questionnaire'), + export_csv, name='export_csv'), + url(r'^(?P[^/]+)/progress/$', + get_async_progress, name='progress'), ) + +if not use_session: + urlpatterns += patterns( + '', + url(r'^(?P[^/]+)/(?P[-]{0,1}\d+)/$', + questionnaire, name='questionset'), + url(r'^(?P[^/]+)/$', + questionnaire, name='questionnaire'), + ) +else: + urlpatterns += patterns( + '', + url(r'^$', + questionnaire, name='questionnaire'), + ) diff --git a/questionnaire/views.py b/questionnaire/views.py index f11d11f..7efa99a 100644 --- a/questionnaire/views.py +++ b/questionnaire/views.py @@ -27,6 +27,13 @@ import random from hashlib import md5 import re + +try: + use_session = settings.QUESTIONNAIRE_USE_SESSION +except AttributeError: + use_session = False + + def r2r(tpl, request, **contextdict): "Shortcut to use RequestContext instead of Context in templates" contextdict['request'] = request @@ -51,7 +58,7 @@ def delete_answer(question, subject, runid): def add_answer(runinfo, question, answer_dict): """ Add an Answer to a Question for RunInfo, given the relevant form input - + answer_dict contains the POST'd elements for this question, minus the question_{number} prefix. The question_{number} form value is accessible with the ANSWER key. @@ -76,7 +83,7 @@ def add_answer(runinfo, question, answer_dict): # then save the new answer to the database answer.save(runinfo) - + return True def check_parser(runinfo, exclude=[]): @@ -101,7 +108,7 @@ def check_parser(runinfo, exclude=[]): checks = parse_checks(checks) for check, value in checks.items(): - if check in fnmap: + if check in fnmap: value = value and value.strip() if not fnmap[check](value): return False @@ -130,9 +137,9 @@ def questionset_satisfies_checks(questionset, runinfo, checks=None): """Return True if the runinfo passes the checks specified in the QuestionSet Checks is an optional dictionary with the keys being questionset.pk and the - values being the checks of the contained questions. - - This, in conjunction with fetch_checks allows for fewer + values being the checks of the contained questions. + + This, in conjunction with fetch_checks allows for fewer db roundtrips and greater performance. Sadly, checks cannot be hashed and therefore the request cache is useless @@ -169,7 +176,7 @@ def questionset_satisfies_checks(questionset, runinfo, checks=None): def get_progress(runinfo): position, total = 0, 0 - + current = runinfo.questionset sets = current.questionnaire.questionsets() @@ -190,27 +197,34 @@ def get_progress(runinfo): progress = 1 else: progress = float(position) / float(total) * 100.00 - + # progress is always at least one percent progress = progress >= 1.0 and progress or 1 return int(progress) -def get_async_progress(request, runcode, *args, **kwargs): +def get_async_progress(request, *args, **kwargs): """ Returns the progress as json for use with ajax """ + if 'runcode' in kwargs: + runcode = kwargs['runcode'] + else: + session_runcode = request.session.get('runcode', None) + if session_runcode is not None: + runcode = session_runcode + runinfo = get_runinfo(runcode) response = dict(progress=get_progress(runinfo)) cache.set('progress' + runinfo.random, response['progress']) - response = HttpResponse(json.dumps(response), + response = HttpResponse(json.dumps(response), mimetype='application/javascript'); response["Cache-Control"] = "no-cache" return response def fetch_checks(questionsets): ids = [qs.pk for qs in questionsets] - + query = Question.objects.filter(questionset__pk__in=ids) query = query.values('questionset_id', 'checks', 'number') @@ -225,7 +239,8 @@ def fetch_checks(questionsets): return checks -def redirect_to_qs(runinfo): + +def redirect_to_qs(runinfo, request=None): "Redirect to the correct and current questionset URL for this RunInfo" # cache current questionset @@ -233,12 +248,12 @@ def redirect_to_qs(runinfo): # skip questionsets that don't pass if not questionset_satisfies_checks(runinfo.questionset, runinfo): - + next = runinfo.questionset.next() while next and not questionset_satisfies_checks(next, runinfo): next = next.next() - + runinfo.questionset = next runinfo.save() @@ -251,10 +266,18 @@ def redirect_to_qs(runinfo): logging.warn('no questionset in questionnaire which passes the check') return finish_questionnaire(runinfo, qs.questionnaire) - url = reverse("questionset", - args=[ runinfo.random, runinfo.questionset.sortid ]) + if not use_session: + args = [runinfo.random, runinfo.questionset.sortid] + urlname = 'questionset' + else: + args = [] + request.session['qs'] = runinfo.questionset.sortid + request.session['runcode'] = runinfo.random + urlname = 'questionnaire' + url = reverse(urlname, args=args) return HttpResponseRedirect(url) + @transaction.commit_on_success def questionnaire(request, runcode=None, qs=None): """ @@ -269,6 +292,14 @@ def questionnaire(request, runcode=None, qs=None): We only commit on success, to maintain consistency. We also specifically rollback if there were errors processing the answers for this questionset. """ + if use_session: + session_runcode = request.session.get('runcode', None) + if session_runcode is not None: + runcode = session_runcode + + session_qs = request.session.get('qs', None) + if session_qs is not None: + qs = session_qs # if runcode provided as query string, redirect to the proper page if not runcode: @@ -276,7 +307,12 @@ def questionnaire(request, runcode=None, qs=None): if not runcode: return HttpResponseRedirect("/") else: - return HttpResponseRedirect(reverse("questionnaire",args=[runcode])) + if not use_session: + args = [runcode, ] + else: + request.session['runcode'] = runcode + args = [] + return HttpResponseRedirect(reverse("questionnaire", args=args)) runinfo = get_runinfo(runcode) @@ -300,7 +336,7 @@ def questionnaire(request, runcode=None, qs=None): return set_language(request, runinfo, request.path) # -------------------------------- - # --- Handle non-POST requests --- + # --- Handle non-POST requests --- # -------------------------------- if request.method != "POST": @@ -310,13 +346,13 @@ def questionnaire(request, runcode=None, qs=None): pass # ok for testing elif qs.sortid > runinfo.questionset.sortid: # you may jump back, but not forwards - return redirect_to_qs(runinfo) + return redirect_to_qs(runinfo, request) runinfo.questionset = qs runinfo.save() transaction.commit() # no questionset id in URL, so redirect to the correct URL if qs is None: - return redirect_to_qs(runinfo) + return redirect_to_qs(runinfo, request) return show_questionnaire(request, runinfo) # ------------------------------------- @@ -325,7 +361,7 @@ def questionnaire(request, runcode=None, qs=None): # if the submitted page is different to what runinfo says, update runinfo # XXX - do we really want this? - qs = request.POST.get('questionset_id', None) + qs = request.POST.get('questionset_id', qs) try: qsobj = QuestionSet.objects.filter(pk=qs)[0] if qsobj.questionnaire == runinfo.questionset.questionnaire: @@ -415,7 +451,7 @@ def questionnaire(request, runcode=None, qs=None): return finish_questionnaire(runinfo, questionnaire) transaction.commit() - return redirect_to_qs(runinfo) + return redirect_to_qs(runinfo, request) def finish_questionnaire(runinfo, questionnaire): hist = RunInfoHistory() @@ -462,7 +498,7 @@ def show_questionnaire(request, runinfo, errors={}): show_all = request.GET.get('show_all') == '1' # for debugging purposes in some cases we may want to show all questions on one screen. questionset = runinfo.questionset - questions = questionset.questionnaire.questions() if show_all else questionset.questions() + questions = questionset.questionnaire.questions() if show_all else questionset.questions() qlist = [] jsinclude = [] # js files to include @@ -470,7 +506,7 @@ def show_questionnaire(request, runinfo, errors={}): jstriggers = [] qvalues = {} - # initialize qvalues + # initialize qvalues cookiedict = runinfo.get_cookiedict() for k,v in cookiedict.items(): @@ -496,7 +532,7 @@ def show_questionnaire(request, runinfo, errors={}): 'qalpha_class' : _qalpha and (ord(_qalpha[-1]) % 2 \ and ' alodd' or ' aleven') or '', } - + # substitute answer texts substitute_answer(qvalues, question) @@ -522,9 +558,9 @@ def show_questionnaire(request, runinfo, errors={}): jstriggers.extend(qdict['jstriggers']) if 'qvalue' in qdict and not question.number in cookiedict: qvalues[question.number] = qdict['qvalue'] - + qlist.append( (question, qdict) ) - + try: has_progress = settings.QUESTIONNAIRE_PROGRESS in ('async', 'default') async_progress = settings.QUESTIONNAIRE_PROGRESS == 'async' @@ -578,7 +614,7 @@ def substitute_answer(qvalues, obj): Only answers with 'store' in their check will work with this. """ - + if qvalues and obj.text: magic = 'subst_with_ans_' regex =r'subst_with_ans_(\S+)' @@ -587,14 +623,14 @@ def substitute_answer(qvalues, obj): text_attributes = [a for a in dir(obj) if a.startswith('text_')] for answerid in replacements: - + target = magic + answerid replacement = qvalues.get(answerid.lower(), '') for attr in text_attributes: oldtext = getattr(obj, attr) newtext = oldtext.replace(target, replacement) - + setattr(obj, attr, newtext) @@ -715,14 +751,14 @@ def answer_export(questionnaire, answers=None): questionnaire -- questionnaire model for export answers -- query set of answers to include in export, defaults to all - Return a flat dump of column headings and all the answers for a - questionnaire (in query set answers) in the form (headings, answers) + Return a flat dump of column headings and all the answers for a + questionnaire (in query set answers) in the form (headings, answers) where headings is: ['question1 number', ...] and answers is: [(subject1, 'runid1', ['answer1.1', ...]), ... ] - The headings list might include items with labels like + The headings list might include items with labels like 'questionnumber-freeform'. Those columns will contain all the freeform answers for that question (separated from the other answer data). @@ -731,7 +767,7 @@ def answer_export(questionnaire, answers=None): The items in the answers list are unicode strings or empty strings if no answer was given. The number of elements in each answer list will - always match the number of headings. + always match the number of headings. """ if answers is None: answers = Answer.objects.all() @@ -756,14 +792,14 @@ def answer_export(questionnaire, answers=None): row = [] for answer in answers: if answer.runid != runid or answer.subject != subject: - if row: + if row: out.append((subject, runid, row)) runid = answer.runid subject = answer.subject row = [""] * len(headings) ans = answer.split_answer() if type(ans) == int: - ans = str(ans) + ans = str(ans) for choice in ans: col = None if type(choice) == list: @@ -781,7 +817,7 @@ def answer_export(questionnaire, answers=None): if col is not None: row[col] = choice # and don't forget about the last one - if row: + if row: out.append((subject, runid, row)) return headings, out @@ -791,13 +827,13 @@ def answer_summary(questionnaire, answers=None): answers -- query set of answers to include in summary, defaults to all Return a summary of the answer totals in answer_qs in the form: - [('q1', 'question1 text', - [('choice1', 'choice1 text', num), ...], + [('q1', 'question1 text', + [('choice1', 'choice1 text', num), ...], ['freeform1', ...]), ...] questions are returned in questionnaire order choices are returned in question order - freeform options are case-insensitive sorted + freeform options are case-insensitive sorted """ if answers is None: @@ -834,7 +870,7 @@ def answer_summary(questionnaire, answers=None): summary.append((question.number, question.text, [ (n, t, choice_totals[n]) for (n, t) in choices], freeforms)) return summary - + def has_tag(tag, runinfo): """ Returns true if the given runinfo contains the given tag. """ return tag in (t.strip() for t in runinfo.tags.split(',')) @@ -859,7 +895,7 @@ def dep_check(expr, runinfo, answerdict): 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. """ @@ -908,7 +944,7 @@ def dep_check(expr, runinfo, answerdict): if not actual_answer: if check_question.getcheckdict(): actual_answer = check_question.getcheckdict().get('default') - + if actual_answer is None: actual_answer = u'' if check_answer[0:1] in "<>": @@ -969,6 +1005,9 @@ def generate_run(request, questionnaire_id): run = RunInfo(subject=su, random=key, runid=key, questionset=qs) run.save() - - return HttpResponseRedirect(reverse('questionnaire', kwargs={'runcode': key})) - + if not use_session: + kwargs = {'runcode': key} + else: + kwargs = {} + request.session['runcode'] = key + return HttpResponseRedirect(reverse('questionnaire', kwargs=kwargs)) From 245ae4e15cf8e169a5db92ca84f0922c2b44274e Mon Sep 17 00:00:00 2001 From: "Julio C. Menendez" Date: Sun, 14 Sep 2014 09:36:08 -0600 Subject: [PATCH 2/3] Fixes returning to previous page when using sessions. --- .../templates/questionnaire/questionset.html | 28 +++++++++---------- questionnaire/urls.py | 3 ++ questionnaire/views.py | 27 +++++++++++++++++- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/questionnaire/templates/questionnaire/questionset.html b/questionnaire/templates/questionnaire/questionset.html index 88d3372..ef7fe4f 100644 --- a/questionnaire/templates/questionnaire/questionset.html +++ b/questionnaire/templates/questionnaire/questionset.html @@ -10,7 +10,7 @@ {% for x in jsinclude %} {% endfor %} - + {% for x in cssinclude %} {% endfor %} @@ -44,15 +44,15 @@
- + {% csrf_token %} - + {% for question, qdict in qlist %} {% with errors|dictget:question.number as error %} -
+
{% if request.user.is_staff %} @@ -60,7 +60,7 @@ {% endif %} - + {% if qdict.custom %} {% if error %}
@@ -70,7 +70,7 @@ {% include qdict.template %} {% else %}
- {{ question.display_number }}. + {{ question.display_number }}. {{ question.text }}
@@ -89,16 +89,16 @@ {% endif %} {% endwith %} {% endfor %} - - - + + +
- +
- + {% if questionset.prev %} - {% trans "return to previous page" %} + {% trans "return to previous page" %} {% endif %} @@ -106,11 +106,11 @@ {% for trigger in triggers %} addtrigger("{{trigger}}"); {% endfor %} - + {% for k,v in qvalues.items %} qvalues['{{ k|escapejs }}'] = '{{ v|escapejs }}'; {% endfor %} - + for(key in qvalues) { valchanged(key, qvalues[key]); } diff --git a/questionnaire/urls.py b/questionnaire/urls.py index 572e89b..a29e2ee 100644 --- a/questionnaire/urls.py +++ b/questionnaire/urls.py @@ -27,4 +27,7 @@ else: '', url(r'^$', questionnaire, name='questionnaire'), + url(r'^prev/$', + redirect_to_prev_questionnaire, + name='redirect_to_prev_questionnaire') ) diff --git a/questionnaire/views.py b/questionnaire/views.py index 7efa99a..4994b7a 100644 --- a/questionnaire/views.py +++ b/questionnaire/views.py @@ -278,6 +278,24 @@ def redirect_to_qs(runinfo, request=None): return HttpResponseRedirect(url) +def redirect_to_prev_questionnaire(request): + """ + Used only when ```QUESTIONNAIRE_USE_SESSION``` is True. + Takes the questionnaire set in the session and redirects to the + previous questionnaire if any. + """ + runcode = request.session.get('runcode', None) + if runcode is not None: + runinfo = get_runinfo(runcode) + prev_qs = runinfo.questionset.prev() + if runinfo and prev_qs: + request.session['runcode'] = runinfo.random + request.session['qs'] = prev_qs.sortid + return HttpResponseRedirect(reverse('questionnaire')) + + return HttpResponseRedirect('/') + + @transaction.commit_on_success def questionnaire(request, runcode=None, qs=None): """ @@ -446,6 +464,8 @@ def questionnaire(request, runcode=None, qs=None): next = next.next() runinfo.questionset = next runinfo.save() + if use_session: + request.session['prev_runcode'] = runinfo.random if next is None: # we are finished return finish_questionnaire(runinfo, questionnaire) @@ -587,6 +607,10 @@ def show_questionnaire(request, runinfo, errors={}): else: qvalues[s[1]] = v + if use_session: + prev_url = reverse('redirect_to_prev_questionnaire') + else: + prev_url = 'javascript:history.back();' r = r2r("questionnaire/questionset.html", request, questionset=runinfo.questionset, runinfo=runinfo, @@ -598,7 +622,8 @@ def show_questionnaire(request, runinfo, errors={}): jsinclude=jsinclude, cssinclude=cssinclude, async_progress=async_progress, - async_url=reverse('progress', args=[runinfo.random]) + async_url=reverse('progress', args=[runinfo.random]), + prev_url=prev_url, ) r['Cache-Control'] = 'no-cache' r['Expires'] = "Thu, 24 Jan 1980 00:00:00 GMT" From 0cd67028f995d4ae8b5efbebd218658fdf37f00d Mon Sep 17 00:00:00 2001 From: "Julio C. Menendez" Date: Mon, 15 Sep 2014 07:28:29 -0600 Subject: [PATCH 3/3] Sets QUESTIONNAIRE_USE_SESSION in the example. Includes a brief explanation of how the setting changes the app behavior. --- example/settings.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/example/settings.py b/example/settings.py index a76a7ac..a45cbb1 100644 --- a/example/settings.py +++ b/example/settings.py @@ -68,7 +68,7 @@ STATIC_URL = '/static/' # Additional locations of static files STATICFILES_DIRS = ( os.path.abspath('./static'), - os.path.abspath('../questionnaire/static/') + os.path.abspath('../questionnaire/static/') ) # List of finder classes that know how to find static files in @@ -129,15 +129,15 @@ LANGUAGES = ( # the possible options are 'default', 'async' and 'none' # # 'default' -# The progressbar will be rendered in each questionset together with the -# questions. This is a good choice for smaller questionnaires as the +# The progressbar will be rendered in each questionset together with the +# questions. This is a good choice for smaller questionnaires as the # progressbar will always be up to date. # # 'async' # The progressbar value is updated using ajax once the questions have been # rendered. This approach is the right choice for bigger questionnaires which # result in a long time spent on updating the progressbar with each request. -# (The progress calculation is by far the most time consuming method in +# (The progress calculation is by far the most time consuming method in # bigger questionnaires as all questionsets and questions need to be # parsed to decide if they play a role in the current run or not) # @@ -146,5 +146,12 @@ LANGUAGES = ( # questionnaire is so huge that even the ajax request takes too long. QUESTIONNAIRE_PROGRESS = 'async' +# Defines how the questionnaire and questionset id are passed around. +# If False, the default value, the ids are part of the URLs and visible to the +# user answering the questions. +# If True the ids are set in the session and the URL remains unchanged as the +# user goes through the steps of the question set. +QUESTIONNAIRE_USE_SESSION = False + try: from local_settings import * except: pass