""" Models to support Course Surveys feature """ import logging from collections import OrderedDict from django.core.exceptions import ValidationError from django.db import models from lxml import etree from model_utils.models import TimeStampedModel from openedx.core.djangoapps.xmodule_django.models import CourseKeyField from student.models import User from survey.exceptions import SurveyFormNameAlreadyExists, SurveyFormNotFound log = logging.getLogger("edx.survey") class SurveyForm(TimeStampedModel): """ Model to define a Survey Form that contains the HTML form data that is presented to the end user. A SurveyForm is not tied to a particular run of a course, to allow for sharing of Surveys across courses """ name = models.CharField(max_length=255, db_index=True, unique=True) form = models.TextField() class Meta(object): app_label = 'survey' def __unicode__(self): return self.name def save(self, *args, **kwargs): """ Override save method so we can validate that the form HTML is actually parseable """ self.validate_form_html(self.form) # now call the actual save method super(SurveyForm, self).save(*args, **kwargs) @classmethod def validate_form_html(cls, html): """ Makes sure that the html that is contained in the form field is valid """ try: fields = cls.get_field_names_from_html(html) except Exception as ex: log.exception("Cannot parse SurveyForm html: {}".format(ex)) raise ValidationError("Cannot parse SurveyForm as HTML: {}".format(ex)) if not len(fields): raise ValidationError("SurveyForms must contain at least one form input field") @classmethod def create(cls, name, form, update_if_exists=False): """ Helper class method to create a new Survey Form. update_if_exists=True means that if a form already exists with that name, then update it. Otherwise throw an SurveyFormAlreadyExists exception """ survey = cls.get(name, throw_if_not_found=False) if not survey: survey = SurveyForm(name=name, form=form) else: if update_if_exists: survey.form = form else: raise SurveyFormNameAlreadyExists() survey.save() return survey @classmethod def get(cls, name, throw_if_not_found=True): """ Helper class method to look up a Survey Form, throw FormItemNotFound if it does not exists in the database, unless throw_if_not_found=False then we return None """ survey = None exists = SurveyForm.objects.filter(name=name).exists() if exists: survey = SurveyForm.objects.get(name=name) elif throw_if_not_found: raise SurveyFormNotFound() return survey def get_answers(self, user=None, limit_num_users=10000): """ Returns all answers for all users for this Survey """ return SurveyAnswer.get_answers(self, user, limit_num_users=limit_num_users) def has_user_answered_survey(self, user): """ Returns whether a given user has supplied answers to this survey """ return SurveyAnswer.do_survey_answers_exist(self, user) def save_user_answers(self, user, answers, course_key): """ Store answers to the form for a given user. Answers is a dict of simple name/value pairs IMPORTANT: There is no validaton of form answers at this point. All data supplied to this method is presumed to be previously validated """ # first remove any answer the user might have done before self.clear_user_answers(user) SurveyAnswer.save_answers(self, user, answers, course_key) def clear_user_answers(self, user): """ Removes all answers that a user has submitted """ SurveyAnswer.objects.filter(form=self, user=user).delete() def get_field_names(self): """ Returns a list of defined field names for all answers in a survey. This can be helpful for reporting like features, i.e. adding headers to the reports This is taken from the set of <input> fields inside the form. """ return SurveyForm.get_field_names_from_html(self.form) @classmethod def get_field_names_from_html(cls, html): """ Returns a list of defined field names from a block of HTML """ names = [] # make sure the form is wrap in some outer single element # otherwise lxml can't parse it # NOTE: This wrapping doesn't change the ability to query it tree = etree.fromstring(u'<div>{}</div>'.format(html)) input_fields = ( tree.findall('.//input') + tree.findall('.//select') + tree.findall('.//textarea') ) for input_field in input_fields: if 'name' in input_field.keys() and input_field.attrib['name'] not in names: names.append(input_field.attrib['name']) return names class SurveyAnswer(TimeStampedModel): """ Model for the answers that a user gives for a particular form in a course """ user = models.ForeignKey(User, db_index=True) form = models.ForeignKey(SurveyForm, db_index=True) field_name = models.CharField(max_length=255, db_index=True) field_value = models.CharField(max_length=1024) # adding the course_id where the end-user answered the survey question # since it didn't exist in the beginning, it is nullable course_key = CourseKeyField(max_length=255, db_index=True, null=True) class Meta(object): app_label = 'survey' @classmethod def do_survey_answers_exist(cls, form, user): """ Returns whether a user has any answers for a given SurveyForm for a course This can be used to determine if a user has taken a CourseSurvey. """ if user.is_anonymous(): return False return SurveyAnswer.objects.filter(form=form, user=user).exists() @classmethod def get_answers(cls, form, user=None, limit_num_users=10000): """ Returns all answers a user (or all users, when user=None) has given to an instance of a SurveyForm Return is a nested dict which are simple name/value pairs with an outer key which is the user id. For example (where 'field3' is an optional field): results = { '1': { 'field1': 'value1', 'field2': 'value2', }, '2': { 'field1': 'value3', 'field2': 'value4', 'field3': 'value5', } : : } limit_num_users is to prevent an unintentional huge, in-memory dictionary. """ if user: answers = SurveyAnswer.objects.filter(form=form, user=user) else: answers = SurveyAnswer.objects.filter(form=form) results = OrderedDict() num_users = 0 for answer in answers: user_id = answer.user.id if user_id not in results and num_users < limit_num_users: results[user_id] = OrderedDict() num_users = num_users + 1 if user_id in results: results[user_id][answer.field_name] = answer.field_value return results @classmethod def save_answers(cls, form, user, answers, course_key): """ Store answers to the form for a given user. Answers is a dict of simple name/value pairs IMPORTANT: There is no validaton of form answers at this point. All data supplied to this method is presumed to be previously validated """ for name in answers.keys(): value = answers[name] # See if there is an answer stored for this user, form, field_name pair or not # this will allow for update cases. This does include an additional lookup, # but write operations will be relatively infrequent value = answers[name] defaults = {"field_value": value} if course_key: defaults['course_key'] = course_key answer, created = SurveyAnswer.objects.get_or_create( user=user, form=form, field_name=name, defaults=defaults ) if not created: # Allow for update cases. answer.field_value = value answer.course_key = course_key answer.save()