""" Models to support Course Surveys feature """ import logging from collections import OrderedDict from django.core.exceptions import ValidationError from django.db import models from django.utils.encoding import python_2_unicode_compatible from lxml import etree from model_utils.models import TimeStampedModel from opaque_keys.edx.django.models import CourseKeyField from common.djangoapps.student.models import User from lms.djangoapps.survey.exceptions import SurveyFormNameAlreadyExists, SurveyFormNotFound from openedx.core.djangolib.markup import HTML log = logging.getLogger("edx.survey") @python_2_unicode_compatible 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 .. no_pii: """ name = models.CharField(max_length=255, db_index=True, unique=True) form = models.TextField() class Meta: app_label = 'survey' def __str__(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().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(f"Cannot parse SurveyForm html: {ex}") raise ValidationError(f"Cannot parse SurveyForm as HTML: {ex}") # lint-amnesty, pylint: disable=raise-missing-from if not len(fields): # lint-amnesty, pylint: disable=len-as-condition 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 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(HTML('