import hashlib import json import re import uuid from datetime import datetime from six import text_type as unicodestr 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.urls 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) __str__ = __unicode__ 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 __str__ = __unicode__ 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 __str__ = __unicode__ class QuestionSet(models.Model, metaclass=TransMeta): "Which questions to display on a question page" questionnaire = models.ForeignKey(Questionnaire, on_delete=models.CASCADE) 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", default="",) 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 __next__ = next 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) __str__ = __unicode__ 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, on_delete=models.CASCADE) 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, unicodestr, 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) __str__ = __unicode__ class Meta: verbose_name_plural = 'Run Info' index_together = [ ["random"], ] class RunInfoHistory(models.Model): subject = models.ForeignKey(Subject, on_delete=models.CASCADE) 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, on_delete=models.CASCADE) def __unicode__(self): return "%s: %s on %s" % (self.run.runid, self.subject, self.completed) __str__ = __unicode__ 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, on_delete=models.CASCADE) 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, default="", 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", default="", null=True, 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' % (unicodestr(self.questionset), self.number, self.text) __str__ = __unicode__ 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, on_delete=models.CASCADE) sortid = models.IntegerField() value = models.CharField(u"Short Value", max_length=64, default="") text = models.CharField(u"Choice Text", max_length=200, default="") 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) __str__ = __unicode__ 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) __str__ = __unicode__ 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 unicodestr(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'], ]