import hashlib import json import re import uuid from datetime import datetime from transmeta import TransMeta from django.conf import settings from django.contrib.contenttypes.fields 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 from . import QuestionChoices from .utils import split_numal from .parsers import parse_checks, ParseException _numre = re.compile("(\d+)([a-z]+)", re.I) class Subject(models.Model): STATE_CHOICES = [ ("active", _("Active")), ("inactive", _("Inactive")), # 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')) anonymous = models.BooleanField(default=False) ip_address = models.GenericIPAddressField(null=True, blank=True) 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')) gender = models.CharField(max_length=8, default="unset", blank=True, verbose_name=_('Gender'), choices = ( ("unset", _("Unset")), ("male", _("Male")), ("female", _("Female")), ) ) 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"),)) ) language = models.CharField(max_length=5, default=settings.LANGUAGE_CODE, verbose_name = _('Language'), choices = settings.LANGUAGES) def __unicode__(self): if self.anonymous: return self.ip_address else: return u'%s, %s (%s)' % (self.surname, self.givenname, self.email) def next_runid(self): "Return the string form of the runid for the upcoming run" return str(self.nextrun.year) 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 def history(self): return RunInfoHistory.objects.filter(subject=self).order_by('run__runid') def pending(self): return RunInfo.objects.filter(subject=self).order_by('run__runid') class Meta: index_together = [ ["givenname", "surname"], ] class GlobalStyles(models.Model): content = models.TextField() class Questionnaire(models.Model): name = models.CharField(max_length=128) 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) html = models.TextField(u'Html', blank=True) parse_html = models.BooleanField("Render html instead of name for questionnaire?", null=False, default=False) admin_access_only = models.BooleanField("Only allow access to logged in users? (This allows entering paper questionnaires without allowing new external submissions)", null=False, default=False) 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 def questions(self): questions = [] for questionset in self.questionsets(): questions += questionset.questions() return questions class Meta: permissions = ( ("export", "Can export questionnaire answers"), ("management", "Management Tools") ) 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, on_delete=models.CASCADE, 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, on_delete=models.CASCADE, null=True, blank=True, related_name='landings') def _hash(self): return uuid.uuid4().hex def __str__(self): return self.label def url(self): try: return settings.BASE_URL_SECURE + reverse('landing', args=[self.nonce]) except AttributeError: # not using sites return 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) class DBStylesheet(models.Model): #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) content = models.TextField() def __unicode__(self): return self.inclusion_tag 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) checks = models.CharField(max_length=256, blank=True, help_text = """Current options are 'femaleonly' or 'maleonly' and shownif="QuestionNumber,Answer" which takes the same format as requiredif for questions.""") text = models.TextField(u'Text', help_text="HTML or Text") parse_html = models.BooleanField("Render html in heading?", null=False, default=False) def questions(self): if not hasattr(self, "__qcache"): def numeric_number(val): matches = re.findall(r'^\d+', val) return int(matches[0]) if matches else 0 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 return self.__qcache def sorted_questions(self): questions = self.questions() return sorted(questions, key = lambda question : (question.sort_id, question.number)) 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',) index_together = [ ["questionnaire", "sortid"], ["sortid",] ] class Run(models.Model): runid = models.CharField(max_length=32, null=True) 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 run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name='run_infos') landing = models.ForeignKey(Landing, on_delete=models.CASCADE, null=True, blank=True) # 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, on_delete=models.CASCADE, blank=True, null=True) # or straight int? emailcount = models.IntegerField(default=0) created = models.DateTimeField(auto_now_add=True) emailsent = models.DateTimeField(null=True, blank=True) lastemailerror = models.CharField(max_length=64, null=True, blank=True) state = models.CharField(max_length=16, null=True, blank=True) cookies = models.TextField(null=True, blank=True) tags = models.TextField( blank=True, help_text=u"Tags active on this run, separated by commas" ) skipped = models.TextField( blank=True, help_text=u"A comma sepearted list of questions to skip" ) def save(self, **kwargs): self.random = (self.random or '').lower() super(RunInfo, self).save(**kwargs) 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) 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() if type(value) not in (int, float, str, unicode, type(None)): raise Exception("Can only store cookies of type integer or string") if value is None: if key in cookies: del cookies[key] else: if type(value) in ('int', 'float'): value=str(value) cookies[key] = value 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() return d.get(key.lower().strip(), default) 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.run.runid, self.subject.surname, self.subject.givenname) class Meta: verbose_name_plural = 'Run Info' index_together = [ ["random"], ] class RunInfoHistory(models.Model): subject = models.ForeignKey(Subject) run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name='run_info_histories') completed = models.DateTimeField() landing = models.ForeignKey(Landing, on_delete=models.CASCADE, null=True, blank=True) tags = models.TextField( blank=True, help_text=u"Tags used on this run, separated by commas" ) skipped = models.TextField( blank=True, help_text=u"A comma sepearted list of questions skipped by this run" ) questionnaire = models.ForeignKey(Questionnaire) def __unicode__(self): return "%s: %s on %s" % (self.run.runid, self.subject, self.completed) def answers(self): "Returns the query for the answers." return Answer.objects.filter(subject=self.subject, run=self.run) class Meta: verbose_name_plural = 'Run Info History' class Question(models.Model): __metaclass__ = TransMeta questionset = models.ForeignKey(QuestionSet) number = models.CharField(max_length=8, help_text= "eg. 1, 2a, 2b, 3c
" "Number is also used for ordering questions.") sort_id = models.IntegerField(null=True, blank=True, help_text="Questions within a questionset are sorted by sort order first, question number second") text = models.TextField(blank=True, verbose_name=_("Text")) 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'.") 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, null=True, help_text="Additional checks to be performed for this " "value (space separated)

" "For text fields, required is a valid check.
" "For yes/no choice, required, required-yes, " "and required-no are valid.

" "If this question is required only if another question's answer is " 'something specific, use requiredif="QuestionNumber,Value" ' 'or requiredif="QuestionNumber,!Value" for anything but ' "a specific value. " "You may also combine tests appearing in requiredif " "by joining them with the words and or or, " 'eg. requiredif="Q1,A or Q2,B"') footer = models.TextField(u"Footer", help_text="Footer rendered below the question", blank=True) parse_html = models.BooleanField("Render html in Footer?", null=False, default=False) 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 '') except ParseException: 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) def sameas(self): if self.type == 'sameas': try: kwargs = {} for check, value in parse_checks(self.checks): if check == 'sameasid': kwargs['id'] = value break elif check == 'sameas': kwargs['number'] = value kwargs['questionset__questionnaire'] = self.questionset.questionnaire break self.__sameas = res = getattr(self, "__sameas", Question.objects.get(**kwargs)) return res except Question.DoesNotExist: return Question(type='comment') # replace with something benign 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 return self.number def choices(self): if self.type == 'sameas': return self.sameas().choices() 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') 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= in the additional checks field") return cd.get('type') return t def questioninclude(self): return "questionnaire/" + self.get_type() + ".html" @property def is_comment(self): return self.type == 'comment' def get_value_for_run_question(self, runid): runanswer = Answer.objects.filter(run__runid=runid, question=self) if len(runanswer) > 0: return runanswer[0].answer else: return None class Meta: translate = ('text', 'extra', 'footer') index_together = [ ["number", "questionset"], ] 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) tags = models.CharField(u"Tags", max_length=64, blank=True) def __unicode__(self): return u'(%s) %d. %s' % (self.question.number, self.sortid, self.text) class Meta: translate = ('text',) index_together = [ ['value'], ] class Answer(models.Model): subject = models.ForeignKey(Subject, on_delete=models.CASCADE, help_text = u'The user who supplied this answer') question = models.ForeignKey(Question, on_delete=models.CASCADE, help_text = u"The question that this is an answer to") run = models.ForeignKey(Run, on_delete=models.CASCADE, related_name='answers') answer = models.TextField() def __unicode__(self): return "Answer(%s: %s, %s)" % (self.question.number, self.subject.surname, self.subject.givenname) 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: # this was likely saved as plain text, try to guess what the # value(s) were if 'multiple' in self.question.type: return self.answer.split('; ') else: return [self.answer] def check_answer(self): "Confirm that the supplied answer matches what we expect" return True 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() class Meta: index_together = [ ['subject', 'run'], ['subject', 'run', 'id'], ]