2016-07-26 21:08:23 +00:00
import hashlib
2013-12-29 03:21:08 +00:00
import json
2016-07-26 21:08:23 +00:00
import re
import uuid
from datetime import datetime
from transmeta import TransMeta
2009-05-17 11:34:55 +00:00
from django . conf import settings
2016-07-26 21:08:23 +00:00
from django . contrib . contenttypes . generic import GenericForeignKey
from django . contrib . contenttypes . models import ContentType
from django . db import models
from django . db . models . signals import post_save
from django . utils . translation import ugettext_lazy as _
from django . core . urlresolvers import reverse
2009-05-17 11:34:55 +00:00
2016-07-26 21:08:23 +00:00
from . import QuestionChoices
from . utils import split_numal
from . parsers import parse_checks , ParseException
2009-05-17 11:34:55 +00:00
2016-07-26 21:08:23 +00:00
_numre = re . compile ( " ( \ d+)([a-z]+) " , re . I )
2009-05-17 11:34:55 +00:00
class Subject ( models . Model ) :
2009-06-15 16:07:14 +00:00
STATE_CHOICES = [
2009-05-17 11:34:55 +00:00
( " active " , _ ( " Active " ) ) ,
( " inactive " , _ ( " Inactive " ) ) ,
2009-06-15 16:07:14 +00:00
# Can be changed from elsewhere with
# Subject.STATE_CHOICES[:] = [ ('blah', 'Blah') ]
]
state = models . CharField ( max_length = 16 , default = " inactive " ,
choices = STATE_CHOICES , verbose_name = _ ( ' State ' ) )
2016-07-26 21:08:23 +00:00
anonymous = models . BooleanField ( default = False )
ip_address = models . GenericIPAddressField ( null = True , blank = True )
2009-06-15 16:07:14 +00:00
surname = models . CharField ( max_length = 64 , blank = True , null = True ,
verbose_name = _ ( ' Surname ' ) )
givenname = models . CharField ( max_length = 64 , blank = True , null = True ,
verbose_name = _ ( ' Given name ' ) )
email = models . EmailField ( null = True , blank = True , verbose_name = _ ( ' Email ' ) )
2009-05-17 11:34:55 +00:00
gender = models . CharField ( max_length = 8 , default = " unset " , blank = True ,
2009-06-15 16:07:14 +00:00
verbose_name = _ ( ' Gender ' ) ,
2009-05-17 11:34:55 +00:00
choices = ( ( " unset " , _ ( " Unset " ) ) ,
( " male " , _ ( " Male " ) ) ,
( " female " , _ ( " Female " ) ) ,
)
)
2009-06-15 16:07:14 +00:00
nextrun = models . DateField ( verbose_name = _ ( ' Next Run ' ) , blank = True , null = True )
formtype = models . CharField ( max_length = 16 , default = ' email ' ,
verbose_name = _ ( ' Form Type ' ) ,
choices = (
( " email " , _ ( " Subject receives emails " ) ) ,
( " paperform " , _ ( " Subject is sent paper form " ) , ) )
2009-05-17 11:34:55 +00:00
)
2016-07-26 21:08:23 +00:00
language = models . CharField ( max_length = 5 , default = settings . LANGUAGE_CODE ,
2009-06-15 16:07:14 +00:00
verbose_name = _ ( ' Language ' ) , choices = settings . LANGUAGES )
2009-05-17 11:34:55 +00:00
def __unicode__ ( self ) :
2016-07-26 21:08:23 +00:00
if self . anonymous :
return self . ip_address
else :
return u ' %s , %s ( %s ) ' % ( self . surname , self . givenname , self . email )
2009-05-17 11:34:55 +00:00
def next_runid ( self ) :
" Return the string form of the runid for the upcoming run "
return str ( self . nextrun . year )
2011-12-22 15:30:38 +00:00
def last_run ( self ) :
" Returns the last completed run or None "
try :
query = RunInfoHistory . objects . filter ( subject = self )
return query . order_by ( ' -completed ' ) [ 0 ]
except IndexError :
return None
2009-05-17 11:34:55 +00:00
def history ( self ) :
return RunInfoHistory . objects . filter ( subject = self ) . order_by ( ' runid ' )
def pending ( self ) :
return RunInfo . objects . filter ( subject = self ) . order_by ( ' runid ' )
2016-01-19 13:06:42 +00:00
class Meta :
index_together = [
[ " givenname " , " surname " ] ,
]
2016-07-26 21:08:23 +00:00
2015-12-21 18:14:18 +00:00
class GlobalStyles ( models . Model ) :
content = models . TextField ( )
2009-05-17 11:34:55 +00:00
class Questionnaire ( models . Model ) :
name = models . CharField ( max_length = 128 )
2016-07-26 21:08:23 +00:00
redirect_url = models . CharField ( max_length = 128 , help_text = " URL to redirect to when Questionnaire is complete. Macros: $SUBJECTID, $RUNID, $LANG. Leave blank to render the ' complete.$LANG.html ' template. " , default = " " , blank = True )
2015-12-08 01:00:30 +00:00
html = models . TextField ( u ' Html ' , blank = True )
parse_html = models . BooleanField ( " Render html instead of name for survey? " , null = False , default = False )
2016-02-09 03:30:18 +00:00
admin_access_only = models . BooleanField ( " Only allow access to logged in users? (This allows entering paper surveys without allowing new external submissions) " , null = False , default = False )
2009-05-17 11:34:55 +00:00
def __unicode__ ( self ) :
return self . name
def questionsets ( self ) :
if not hasattr ( self , " __qscache " ) :
self . __qscache = \
QuestionSet . objects . filter ( questionnaire = self ) . order_by ( ' sortid ' )
return self . __qscache
2014-05-05 19:41:48 +00:00
def questions ( self ) :
questions = [ ]
for questionset in self . questionsets ( ) :
questions + = questionset . questions ( )
return questions
2009-12-23 08:42:53 +00:00
class Meta :
permissions = (
( " export " , " Can export questionnaire answers " ) ,
( " management " , " Management Tools " )
)
2016-07-26 21:08:23 +00:00
class Landing ( models . Model ) :
# defines an entry point to a Feedback session
nonce = models . CharField ( max_length = 32 , null = True , blank = True )
content_type = models . ForeignKey ( ContentType , null = True , blank = True , related_name = ' landings ' )
object_id = models . PositiveIntegerField ( null = True , blank = True )
content_object = GenericForeignKey ( ' content_type ' , ' object_id ' )
label = models . CharField ( max_length = 64 , blank = True )
questionnaire = models . ForeignKey ( Questionnaire , null = True , blank = True , related_name = ' landings ' )
def _hash ( self ) :
return uuid . uuid4 ( ) . hex
def __str__ ( self ) :
return self . label
def url ( self ) :
return settings . BASE_URL_SECURE + reverse ( ' landing ' , args = [ self . nonce ] )
def config_landing ( sender , instance , created , * * kwargs ) :
if created :
instance . nonce = instance . _hash ( )
instance . save ( )
post_save . connect ( config_landing , sender = Landing )
2015-03-10 22:47:13 +00:00
2015-12-14 07:26:02 +00:00
class DBStylesheet ( models . Model ) :
2015-12-14 07:46:32 +00:00
#Questionnaire max length of name is 128; Questionset max length of heading
#is 64, and Question associative information is id which is less than 128
#in length
inclusion_tag = models . CharField ( max_length = 128 )
2015-12-14 07:26:02 +00:00
content = models . TextField ( )
2015-12-21 17:43:02 +00:00
def __unicode__ ( self ) :
return self . inclusion_tag
2009-05-17 11:34:55 +00:00
class QuestionSet ( models . Model ) :
__metaclass__ = TransMeta
" Which questions to display on a question page "
questionnaire = models . ForeignKey ( Questionnaire )
sortid = models . IntegerField ( ) # used to decide which order to display in
heading = models . CharField ( max_length = 64 )
2015-01-07 23:52:12 +00:00
checks = models . CharField ( max_length = 256 , blank = True ,
2009-05-17 11:34:55 +00:00
help_text = """ Current options are ' femaleonly ' or ' maleonly ' and shownif= " QuestionNumber,Answer " which takes the same format as <tt>requiredif</tt> for questions. """ )
2016-07-26 21:08:23 +00:00
text = models . TextField ( u ' Text ' , help_text = " HTML or Text " )
2009-05-17 11:34:55 +00:00
2016-07-26 21:08:23 +00:00
parse_html = models . BooleanField ( " Render html in heading? " , null = False , default = False )
2015-12-02 02:46:28 +00:00
2009-05-17 11:34:55 +00:00
def questions ( self ) :
if not hasattr ( self , " __qcache " ) :
2014-11-03 20:01:26 +00:00
def numeric_number ( val ) :
matches = re . findall ( r ' ^ \ d+ ' , val )
return int ( matches [ 0 ] ) if matches else 0
2015-03-10 22:47:13 +00:00
questions_with_sort_id = sorted ( Question . objects . filter ( questionset = self . id ) . exclude ( sort_id__isnull = True ) , key = lambda q : q . sort_id )
questions_with_out_sort_id = sorted ( Question . objects . filter ( questionset = self . id , sort_id__isnull = True ) , key = lambda q : ( numeric_number ( q . number ) , q . number ) )
self . __qcache = questions_with_sort_id + questions_with_out_sort_id
2009-05-17 11:34:55 +00:00
return self . __qcache
2015-12-01 21:51:35 +00:00
def sorted_questions ( self ) :
questions = self . questions ( )
2015-12-08 06:45:24 +00:00
return sorted ( questions , key = lambda question : ( question . sort_id , question . number ) )
2015-12-01 21:51:35 +00:00
2009-05-17 11:34:55 +00:00
def next ( self ) :
qs = self . questionnaire . questionsets ( )
retnext = False
for q in qs :
if retnext :
return q
if q == self :
retnext = True
return None
def prev ( self ) :
qs = self . questionnaire . questionsets ( )
last = None
for q in qs :
if q == self :
return last
last = q
def is_last ( self ) :
try :
return self . questionnaire . questionsets ( ) [ - 1 ] == self
except NameError :
# should only occur if not yet saved
return True
def is_first ( self ) :
try :
return self . questionnaire . questionsets ( ) [ 0 ] == self
except NameError :
# should only occur if not yet saved
return True
def __unicode__ ( self ) :
return u ' %s : %s ' % ( self . questionnaire . name , self . heading )
class Meta :
translate = ( ' text ' , )
2016-01-19 13:06:42 +00:00
index_together = [
[ " questionnaire " , " sortid " ] ,
[ " sortid " , ]
]
2009-05-17 11:34:55 +00:00
class RunInfo ( models . Model ) :
" Store the active/waiting questionnaire runs here "
subject = models . ForeignKey ( Subject )
random = models . CharField ( max_length = 32 ) # probably a randomized md5sum
runid = models . CharField ( max_length = 32 )
2016-07-26 21:08:23 +00:00
landing = models . ForeignKey ( Landing , null = True , blank = True )
2009-05-17 11:34:55 +00:00
# questionset should be set to the first QuestionSet initially, and to null on completion
# ... although the RunInfo entry should be deleted then anyway.
questionset = models . ForeignKey ( QuestionSet , blank = True , null = True ) # or straight int?
emailcount = models . IntegerField ( default = 0 )
created = models . DateTimeField ( auto_now_add = True )
2009-07-02 09:45:55 +00:00
emailsent = models . DateTimeField ( null = True , blank = True )
2009-05-17 11:34:55 +00:00
lastemailerror = models . CharField ( max_length = 64 , null = True , blank = True )
state = models . CharField ( max_length = 16 , null = True , blank = True )
2011-12-30 12:41:09 +00:00
cookies = models . TextField ( null = True , blank = True )
2009-05-17 11:34:55 +00:00
2011-12-21 13:52:35 +00:00
tags = models . TextField (
blank = True ,
help_text = u " Tags active on this run, separated by commas "
)
2012-02-02 15:02:35 +00:00
skipped = models . TextField (
blank = True ,
help_text = u " A comma sepearted list of questions to skip "
)
2012-04-20 12:21:08 +00:00
def save ( self , * * kwargs ) :
2009-06-15 16:07:14 +00:00
self . random = ( self . random or ' ' ) . lower ( )
2012-04-20 12:21:08 +00:00
super ( RunInfo , self ) . save ( * * kwargs )
2009-06-15 16:07:14 +00:00
2014-04-23 12:34:23 +00:00
def add_tags ( self , tags ) :
for tag in tags :
if self . tags :
self . tags + = ' , '
self . tags + = tag
def remove_tags ( self , tags ) :
if not self . tags :
return
current_tags = self . tags . split ( ' , ' )
for tag in tags :
try :
current_tags . remove ( tag )
except ValueError :
pass
self . tags = " , " . join ( current_tags )
2009-05-17 11:34:55 +00:00
def set_cookie ( self , key , value ) :
" runinfo.set_cookie(key, value). If value is None, delete cookie "
key = key . lower ( ) . strip ( )
cookies = self . get_cookiedict ( )
2012-02-03 09:12:50 +00:00
if type ( value ) not in ( int , float , str , unicode , type ( None ) ) :
2009-05-17 11:34:55 +00:00
raise Exception ( " Can only store cookies of type integer or string " )
if value is None :
if key in cookies :
del cookies [ key ]
else :
2012-02-03 09:12:50 +00:00
if type ( value ) in ( ' int ' , ' float ' ) :
2011-09-28 10:31:08 +00:00
value = str ( value )
cookies [ key ] = value
2009-05-17 11:34:55 +00:00
cstr = json . dumps ( cookies )
self . cookies = cstr
self . save ( )
self . __cookiecache = cookies
def get_cookie ( self , key , default = None ) :
if not self . cookies :
return default
d = self . get_cookiedict ( )
2012-01-21 15:03:15 +00:00
return d . get ( key . lower ( ) . strip ( ) , default )
2009-05-17 11:34:55 +00:00
def get_cookiedict ( self ) :
if not self . cookies :
return { }
if not hasattr ( self , ' __cookiecache ' ) :
self . __cookiecache = json . loads ( self . cookies )
return self . __cookiecache
def __unicode__ ( self ) :
return " %s : %s , %s " % ( self . runid , self . subject . surname , self . subject . givenname )
class Meta :
verbose_name_plural = ' Run Info '
2016-01-19 13:06:42 +00:00
index_together = [
[ " random " ] ,
]
2009-05-17 11:34:55 +00:00
class RunInfoHistory ( models . Model ) :
subject = models . ForeignKey ( Subject )
runid = models . CharField ( max_length = 32 )
2016-02-16 05:08:46 +00:00
completed = models . DateTimeField ( )
2016-07-26 21:08:23 +00:00
landing = models . ForeignKey ( Landing , null = True , blank = True )
2012-01-04 15:22:18 +00:00
tags = models . TextField (
blank = True ,
help_text = u " Tags used on this run, separated by commas "
)
2012-02-02 15:02:35 +00:00
skipped = models . TextField (
blank = True ,
help_text = u " A comma sepearted list of questions skipped by this run "
)
2012-01-04 15:22:18 +00:00
questionnaire = models . ForeignKey ( Questionnaire )
2009-05-17 11:34:55 +00:00
def __unicode__ ( self ) :
return " %s : %s on %s " % ( self . runid , self . subject , self . completed )
2012-01-04 15:22:18 +00:00
def answers ( self ) :
" Returns the query for the answers. "
return Answer . objects . filter ( subject = self . subject , runid = self . runid )
2009-05-17 11:34:55 +00:00
class Meta :
verbose_name_plural = ' Run Info History '
2015-03-10 22:47:13 +00:00
2009-05-17 11:34:55 +00:00
class Question ( models . Model ) :
__metaclass__ = TransMeta
questionset = models . ForeignKey ( QuestionSet )
2010-04-19 21:06:28 +00:00
number = models . CharField ( max_length = 8 , help_text =
" eg. <tt>1</tt>, <tt>2a</tt>, <tt>2b</tt>, <tt>3c</tt><br /> "
" Number is also used for ordering questions. " )
2015-03-10 22:47:13 +00:00
sort_id = models . IntegerField ( null = True , blank = True , help_text = " Questions within a questionset are sorted by sort order first, question number second " )
2014-04-30 18:10:18 +00:00
text = models . TextField ( blank = True , verbose_name = _ ( " Text " ) )
2009-05-17 11:34:55 +00:00
type = models . CharField ( u " Type of question " , max_length = 32 ,
choices = QuestionChoices ,
help_text = u " Determines the means of answering the question. " \
" An open question gives the user a single-line textfield, " \
" multiple-choice gives the user a number of choices he/she can " \
" choose from. If a question is multiple-choice, enter the choices " \
" this user can choose from below ' . " )
2014-05-06 16:49:14 +00:00
extra = models . CharField ( u " Extra information " , max_length = 512 , blank = True , null = True , help_text = u " Extra information (use on question type) " )
checks = models . CharField ( u " Additional checks " , max_length = 512 , blank = True ,
2010-04-19 21:06:28 +00:00
null = True , help_text = " Additional checks to be performed for this "
" value (space separated) <br /><br /> "
" For text fields, <tt>required</tt> is a valid check.<br /> "
" For yes/no choice, <tt>required</tt>, <tt>required-yes</tt>, "
" and <tt>required-no</tt> are valid.<br /><br /> "
2010-05-05 13:06:24 +00:00
" If this question is required only if another question ' s answer is "
' something specific, use <tt>requiredif= " QuestionNumber,Value " </tt> '
' or <tt>requiredif= " QuestionNumber,!Value " </tt> for anything but '
" a specific value. "
" You may also combine tests appearing in <tt>requiredif</tt> "
" by joining them with the words <tt>and</tt> or <tt>or</tt>, "
2010-05-05 13:46:27 +00:00
' eg. <tt>requiredif= " Q1,A or Q2,B " </tt> ' )
2016-07-26 21:08:23 +00:00
footer = models . TextField ( u " Footer " , help_text = " Footer rendered below the question " , blank = True )
2009-05-17 11:34:55 +00:00
2016-07-26 21:08:23 +00:00
parse_html = models . BooleanField ( " Render html in Footer? " , null = False , default = False )
2015-12-01 21:51:35 +00:00
2009-05-17 11:34:55 +00:00
def questionnaire ( self ) :
return self . questionset . questionnaire
def getcheckdict ( self ) :
""" getcheckdict returns a dictionary of the values in self.checks """
if ( hasattr ( self , ' __checkdict_cached ' ) ) :
return self . __checkdict_cached
try :
self . __checkdict_cached = d = parse_checks ( self . sameas ( ) . checks or ' ' )
2012-01-24 09:39:30 +00:00
except ParseException :
2009-05-17 11:34:55 +00:00
raise Exception ( " Error Parsing Checks for Question %s : %s " % (
self . number , self . sameas ( ) . checks ) )
return d
def __unicode__ ( self ) :
return u ' { %s } ( %s ) %s ' % ( unicode ( self . questionset ) , self . number , self . text )
2014-05-06 16:49:14 +00:00
2009-05-17 11:34:55 +00:00
def sameas ( self ) :
if self . type == ' sameas ' :
2010-12-01 15:14:10 +00:00
try :
2014-04-25 16:21:38 +00:00
kwargs = { }
2014-04-25 15:53:01 +00:00
for check , value in parse_checks ( self . checks ) :
2014-04-25 16:21:38 +00:00
if check == ' sameasid ' :
kwargs [ ' id ' ] = value
break
elif check == ' sameas ' :
kwargs [ ' number ' ] = value
kwargs [ ' questionset__questionnaire ' ] = self . questionset . questionnaire
2014-04-25 15:53:01 +00:00
break
2014-04-25 16:21:38 +00:00
self . __sameas = res = getattr ( self , " __sameas " , Question . objects . get ( * * kwargs ) )
2010-12-01 15:14:10 +00:00
return res
except Question . DoesNotExist :
return Question ( type = ' comment ' ) # replace with something benign
2009-05-17 11:34:55 +00:00
return self
def display_number ( self ) :
" Return either the number alone or the non-number part of the question number indented "
m = _numre . match ( self . number )
if m :
sub = m . group ( 2 )
return " " + sub
2011-12-15 13:13:17 +00:00
return self . number
2009-05-17 11:34:55 +00:00
def choices ( self ) :
if self . type == ' sameas ' :
return self . sameas ( ) . choices ( )
2015-11-29 08:46:08 +00:00
res = None
if ' samechoicesas ' in parse_checks ( self . checks ) :
number_to_grab_from = parse_checks ( self . checks ) [ ' samechoicesas ' ]
choicesource = Question . objects . get ( number = number_to_grab_from )
if not choicesource == None :
res = Choice . objects . filter ( question = choicesource ) . order_by ( ' sortid ' )
else :
res = Choice . objects . filter ( question = self ) . order_by ( ' sortid ' )
2009-05-17 11:34:55 +00:00
return res
def is_custom ( self ) :
return " custom " == self . sameas ( ) . type
def get_type ( self ) :
" Get the type name, treating sameas and custom specially "
t = self . sameas ( ) . type
if t == ' custom ' :
cd = self . sameas ( ) . getcheckdict ( )
if ' type ' not in cd :
raise Exception ( " When using custom types, you must have type=<name> in the additional checks field " )
return cd . get ( ' type ' )
return t
def questioninclude ( self ) :
return " questionnaire/ " + self . get_type ( ) + " .html "
2015-02-27 13:01:39 +00:00
@property
def is_comment ( self ) :
return self . type == ' comment '
2016-07-26 21:08:23 +00:00
def get_value_for_run_question ( self , runid ) :
runanswer = Answer . objects . filter ( runid = runid , question = self )
if len ( runanswer ) > 0 :
return runanswer [ 0 ] . answer
else :
return None
2009-05-17 11:34:55 +00:00
class Meta :
2012-01-31 15:22:00 +00:00
translate = ( ' text ' , ' extra ' , ' footer ' )
2016-01-19 13:06:42 +00:00
index_together = [
[ " number " , " questionset " ] ,
]
2009-05-17 11:34:55 +00:00
class Choice ( models . Model ) :
__metaclass__ = TransMeta
question = models . ForeignKey ( Question )
sortid = models . IntegerField ( )
value = models . CharField ( u " Short Value " , max_length = 64 )
text = models . CharField ( u " Choice Text " , max_length = 200 )
2014-04-23 12:34:23 +00:00
tags = models . CharField ( u " Tags " , max_length = 64 , blank = True )
2009-05-17 11:34:55 +00:00
def __unicode__ ( self ) :
return u ' ( %s ) %d . %s ' % ( self . question . number , self . sortid , self . text )
class Meta :
translate = ( ' text ' , )
2016-01-19 12:28:15 +00:00
index_together = [
[ ' value ' ] ,
]
2009-05-17 11:34:55 +00:00
class Answer ( models . Model ) :
subject = models . ForeignKey ( Subject , help_text = u ' The user who supplied this answer ' )
question = models . ForeignKey ( Question , help_text = u " The question that this is an answer to " )
runid = models . CharField ( u ' RunID ' , help_text = u " The RunID (ie. year) " , max_length = 32 )
answer = models . TextField ( )
def __unicode__ ( self ) :
return " Answer( %s : %s , %s ) " % ( self . question . number , self . subject . surname , self . subject . givenname )
2010-04-18 17:20:11 +00:00
def split_answer ( self ) :
"""
Decode stored answer value and return as a list of choices .
Any freeform value will be returned in a list as the last item .
Calling code should be tolerant of freeform answers outside
of additional [ ] if data has been stored in plain text format
"""
try :
return json . loads ( self . answer )
except ValueError :
2014-05-06 16:49:14 +00:00
# this was likely saved as plain text, try to guess what the
2010-04-18 17:20:11 +00:00
# value(s) were
if ' multiple ' in self . question . type :
return self . answer . split ( ' ; ' )
else :
return [ self . answer ]
2009-05-17 11:34:55 +00:00
def check_answer ( self ) :
" Confirm that the supplied answer matches what we expect "
2012-04-20 12:21:08 +00:00
return True
2014-04-23 12:34:23 +00:00
def save ( self , runinfo = None , * * kwargs ) :
self . _update_tags ( runinfo )
super ( Answer , self ) . save ( * * kwargs )
def _update_tags ( self , runinfo ) :
if not runinfo :
return
tags_to_add = [ ]
for choice in self . question . choices ( ) :
tags = choice . tags
if not tags :
continue
tags = tags . split ( ' , ' )
runinfo . remove_tags ( tags )
for split_answer in self . split_answer ( ) :
if unicode ( split_answer ) == choice . value :
tags_to_add . extend ( tags )
runinfo . add_tags ( tags_to_add )
runinfo . save ( )
2016-01-19 13:06:42 +00:00
class Meta :
index_together = [
[ ' subject ' , ' runid ' ] ,
[ ' subject ' , ' runid ' , ' id ' ] ,
]