diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 3b63cd3dd38..08a8421578a 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -292,7 +292,7 @@ class Survey(http.Controller): This will take into account the validation errors and store the answers to the questions. If the time limit is reached, errors will be skipped, answers will be ignored and survey state will be forced to 'done'""" - # Validation + # Survey Validation access_data = self._get_access_data(survey_token, answer_token, ensure_token=True) if access_data['validity_code'] is not True: return { @@ -304,33 +304,39 @@ class Survey(http.Controller): page_id=post.get('page_id'), question_id=post.get('question_id')) - errors = {} - for question in questions: - answer_tag = "%s_%s" % (survey_sudo.id, question.id) - errors.update(question.validate_question(post, answer_tag)) - - if errors: - return { - 'error': 'validation', - 'fields': errors, - } - if not answer_sudo.test_entry and not survey_sudo._has_attempts_left(answer_sudo.partner_id, answer_sudo.email, answer_sudo.invite_token): # prevent cheating with users creating multiple 'user_input' before their last attempt return {} - # Submitting survey if not answer_sudo.is_time_limit_reached: + # Prepare answers and comment by question + prepared_questions = {} for question in questions: - answer_tag = "%s_%s" % (survey_sudo.id, question.id) - request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) + answer_full = post.get(str(question.id)) + answer_without_comment, comment = self._extract_comment_from_answers(question, answer_full) + prepared_questions[question.id] = {'answer': answer_without_comment, 'comment': comment} + + # Questions Validation + errors = {} + for question in questions: + answer = prepared_questions[question.id]['answer'] + comment = prepared_questions[question.id]['comment'] + errors.update(question.validate_question(answer, comment)) + if errors: + return {'error': 'validation', 'fields': errors} + + # Submitting questions + for question in questions: + answer = prepared_questions[question.id]['answer'] + comment = prepared_questions[question.id]['comment'] + request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, answer, comment) if answer_sudo.is_time_limit_reached or survey_sudo.questions_layout == 'one_page': answer_sudo._mark_done() elif 'previous_page_id' in post: # Go back to specific page using the breadcrumb. Lines are saved and survey continues return '/survey/fill/%s/%s?previous_page_id=%s' % (survey_sudo.access_token, answer_token, post['previous_page_id']) - elif 'button_submit' in post: + else: next_page, unused = request.env['survey.survey'].next_page_or_question(answer_sudo, page_or_question_id) vals = {'last_displayed_page_id': page_or_question_id} @@ -579,3 +585,42 @@ class Survey(http.Controller): {"text": "Unanswered", "count": answer_perf['skipped']} ]) return values + + def _extract_comment_from_answers(self, question, answers): + """ + As answer is a custom structure depending of the question type + that can contain question answers but also comment that need to be extracted + before validating and saving answers. + If multiple answers, they are listed in an array, except for matrix where answers are structured differently. + See input and output for more info on data structures. + :param question: survey.question + :param answers: {str} answer (for simple questions) + or {list} [answer_1,...,answer_n] (for questions with multiple answers) + or {list} [answer_1, {'comment': comment}] (for questions with simple answers and with comment) + or {list} [answer_1,...,answer_n, {'comment': comment}] (for questions with multiple answers with comment) + or {list} [{sub_question_1: [answer_1,...,answer_n]}, {sub_question_2: [...]}] (for matrix without comment) + or {list} [{sub_question_1: [answer_1,...,answer_n]}, {sub_question_2: [...]}, {'comment': comment}] (for matrix with comment) + :return: answers_no_comment : {str} answer for simple choice (or text and number) question types + or {list} [answer_1,...,answer_n] for multiple choices question types except matrix + or {list} [{sub_question_1: [answer_1,...,answer_n]}, {sub_question_2: [...]}] for matrix + comment : {str} extracted comment for the given question + """ + comment = None + answers_no_comment = [] + if answers: + if question.question_type == 'matrix': + if 'comment' in answers: + comment = answers['comment'].strip() + answers.pop('comment') + answers_no_comment = answers + else: + if not isinstance(answers, list): + answers = [answers] + for answer in answers: + if 'comment' in answer: + comment = answer['comment'].strip() + else: + answers_no_comment.append(answer) + if len(answers_no_comment) == 1: + answers_no_comment = answers_no_comment[0] + return answers_no_comment, comment diff --git a/addons/survey/data/survey_demo_feedback.xml b/addons/survey/data/survey_demo_feedback.xml index 39f24e02d7b..538231a4e61 100644 --- a/addons/survey/data/survey_demo_feedback.xml +++ b/addons/survey/data/survey_demo_feedback.xml @@ -68,11 +68,6 @@ 4 Once a year - - - 5 - Other (answer in comment) - 5 @@ -140,11 +135,6 @@ 8 Poor quality - - - 9 - Other - 8 diff --git a/addons/survey/models/survey_question.py b/addons/survey/models/survey_question.py index c6dfee18a2b..7271e8c88a5 100644 --- a/addons/survey/models/survey_question.py +++ b/addons/survey/models/survey_question.py @@ -152,173 +152,102 @@ class SurveyQuestion(models.Model): self.question_type = False # Validation methods + def validate_question(self, answer, comment=None): + """ Validate question, depending on question type and parameters + for simple choice, text, date and number, answer is simply the answer of the question. + For other multiple choices questions, answer is a list of answers (the selected choices + or a list of selected answers per question -for matrix type-): + - Simple answer : answer = 'example' or 2 or question_answer_id or 2019/10/10 + - Multiple choice : answer = [question_answer_id1, question_answer_id2, question_answer_id3] + - Matrix: answer = { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] } - def validate_question(self, post, answer_tag): - """ Validate question, depending on question type and parameters """ + return dict {question.id (int): error (str)} -> empty dict if no validation error. + """ self.ensure_one() - try: - checker = getattr(self, 'validate_' + self.question_type) - except AttributeError: - _logger.warning(self.question_type + ": This type of question has no validation method") - return {} - else: - return checker(post, answer_tag) - - def validate_free_text(self, post, answer_tag): - self.ensure_one() - errors = {} - answer = post[answer_tag].strip() + if isinstance(answer, str): + answer = answer.strip() # Empty answer to mandatory question - if self.constr_mandatory and not answer: - errors.update({answer_tag: self.constr_error_msg}) - return errors + if self.constr_mandatory and not answer and self.question_type not in ['simple_choice', 'multiple_choice']: + return {self.id: self.constr_error_msg} - def validate_textbox(self, post, answer_tag): - self.ensure_one() - errors = {} - answer = post[answer_tag].strip() - # Empty answer to mandatory question - if self.constr_mandatory and not answer: - errors.update({answer_tag: self.constr_error_msg}) + # because in choices question types, comment can count as answer + if answer or self.question_type in ['simple_choice', 'multiple_choice']: + if self.question_type == 'textbox': + return self._validate_textbox(answer) + elif self.question_type == 'numerical_box': + return self._validate_numerical_box(answer) + elif self.question_type in ['date', 'datetime']: + return self._validate_date(answer) + elif self.question_type in ['simple_choice', 'multiple_choice']: + return self._validate_choice(answer, comment) + elif self.question_type == 'matrix': + return self._validate_matrix(answer) + return {} + + def _validate_textbox(self, answer): # Email format validation - # Note: this validation is very basic: - # all the strings of the form - # @. - # will be accepted - if answer and self.validation_email: + # all the strings of the form "@." will be accepted + if self.validation_email: if not email_validator.match(answer): - errors.update({answer_tag: _('This answer must be an email address')}) + return {self.id: _('This answer must be an email address')} + # Answer validation (if properly defined) # Length of the answer must be in a range - if answer and self.validation_required: + if self.validation_required: if not (self.validation_length_min <= len(answer) <= self.validation_length_max): - errors.update({answer_tag: self.validation_error_msg}) - return errors + return {self.id: self.validation_error_msg} + return {} - def validate_numerical_box(self, post, answer_tag): - self.ensure_one() - errors = {} - answer = post[answer_tag].strip() - # Empty answer to mandatory question - if self.constr_mandatory and not answer: - errors.update({answer_tag: self.constr_error_msg}) - # Checks if user input is a number - if answer: - try: - floatanswer = float(answer) - except ValueError: - errors.update({answer_tag: _('This is not a number')}) - # Answer validation (if properly defined) - if answer and self.validation_required: + def _validate_numerical_box(self, answer): + try: + floatanswer = float(answer) + except ValueError: + return {self.id: _('This is not a number')} + + if self.validation_required: # Answer is not in the right range with tools.ignore(Exception): - floatanswer = float(answer) # check that it is a float has been done hereunder if not (self.validation_min_float_value <= floatanswer <= self.validation_max_float_value): - errors.update({answer_tag: self.validation_error_msg}) - return errors + return {self.id: self.validation_error_msg} + return {} - def date_validation(self, date_type, post, answer_tag, min_value, max_value): - self.ensure_one() - errors = {} - if date_type not in ('date', 'datetime'): - raise ValueError("Unexpected date type value") - answer = post[answer_tag].strip() - # Empty answer to mandatory question - if self.constr_mandatory and not answer: - errors.update({answer_tag: self.constr_error_msg}) + def _validate_date(self, answer): + isDatetime = self.question_type == 'datetime' # Checks if user input is a date - if answer: - try: - if date_type == 'datetime': - dateanswer = fields.Datetime.from_string(answer) - else: - dateanswer = fields.Date.from_string(answer) - except ValueError: - errors.update({answer_tag: _('This is not a date')}) - return errors - # Answer validation (if properly defined) - if answer and self.validation_required: - # Answer is not in the right range - try: - if date_type == 'datetime': - date_from_string = fields.Datetime.from_string - else: - date_from_string = fields.Date.from_string - dateanswer = date_from_string(answer) - min_date = date_from_string(min_value) - max_date = date_from_string(max_value) - - if min_date and max_date and not (min_date <= dateanswer <= max_date): - # If Minimum and Maximum Date are entered - errors.update({answer_tag: self.validation_error_msg}) - elif min_date and not min_date <= dateanswer: - # If only Minimum Date is entered and not Define Maximum Date - errors.update({answer_tag: self.validation_error_msg}) - elif max_date and not dateanswer <= max_date: - # If only Maximum Date is entered and not Define Minimum Date - errors.update({answer_tag: self.validation_error_msg}) - except ValueError: # check that it is a date has been done hereunder - pass - return errors - - def validate_date(self, post, answer_tag): - return self.date_validation('date', post, answer_tag, self.validation_min_date, self.validation_max_date) - - def validate_datetime(self, post, answer_tag): - return self.date_validation('datetime', post, answer_tag, self.validation_min_datetime, self.validation_max_datetime) - - def validate_simple_choice(self, post, answer_tag): - self.ensure_one() - errors = {} - if self.comments_allowed: - comment_tag = "%s_%s" % (answer_tag, 'comment') - # Empty answer to mandatory self - if self.constr_mandatory and answer_tag not in post: - errors.update({answer_tag: self.constr_error_msg}) - if self.constr_mandatory and answer_tag in post and not post[answer_tag].strip(): - errors.update({answer_tag: self.constr_error_msg}) - # Answer is a comment and is empty - if self.constr_mandatory and answer_tag in post and post[answer_tag] == "-1" and self.comment_count_as_answer and comment_tag in post and not post[comment_tag].strip(): - errors.update({answer_tag: self.constr_error_msg}) - return errors - - def validate_multiple_choice(self, post, answer_tag): - self.ensure_one() - errors = {} - if self.constr_mandatory: - answer_candidates = dict_keys_startswith(post, answer_tag) - comment_flag = answer_candidates.pop(("%s_%s" % (answer_tag, -1)), None) - if self.comments_allowed: - comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() - # Preventing answers with blank value - if all(not answer.strip() for answer in answer_candidates.values()) and answer_candidates: - errors.update({answer_tag: self.constr_error_msg}) - # There is no answer neither comments (if comments count as answer) - if not answer_candidates and self.comment_count_as_answer and (not comment_flag or not comment_answer): - errors.update({answer_tag: self.constr_error_msg}) - # There is no answer at all - if not answer_candidates and not self.comment_count_as_answer: - errors.update({answer_tag: self.constr_error_msg}) - return errors - - def validate_matrix(self, post, answer_tag): - self.ensure_one() - errors = {} - if self.constr_mandatory: - lines_number = len(self.labels_ids_2) - answer_candidates = dict_keys_startswith(post, answer_tag) - answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() - # Number of lines that have been answered - if self.matrix_subtype == 'simple': - answer_number = len(answer_candidates) - elif self.matrix_subtype == 'multiple': - answer_number = len({sk.rsplit('_', 1)[0] for sk in answer_candidates}) + try: + dateanswer = fields.Datetime.from_string(answer) if isDatetime else fields.Date.from_string(answer) + except ValueError: + return {self.id: _('This is not a date')} + if self.validation_required: + # Check if answer is in the right range + if isDatetime: + min_date = fields.Datetime.from_string(self.validation_min_datetime) + max_date = fields.Datetime.from_string(self.validation_max_datetime) + dateanswer = fields.Datetime.from_string(answer) else: - raise RuntimeError("Invalid matrix subtype") - # Validate that each line has been answered - if answer_number != lines_number: - errors.update({answer_tag: self.constr_error_msg}) - return errors + min_date = fields.Date.from_string(self.validation_min_date) + max_date = fields.Date.from_string(self.validation_max_date) + dateanswer = fields.Date.from_string(answer) + + if (min_date and max_date and not (min_date <= dateanswer <= max_date))\ + or (min_date and not min_date <= dateanswer)\ + or (max_date and not dateanswer <= max_date): + return {self.id: self.validation_error_msg} + return {} + + def _validate_choice(self, answer, comment): + # Empty comment + if self.constr_mandatory \ + and not answer \ + and not (self.comments_allowed and self.comment_count_as_answer and comment): + return {self.id: self.constr_error_msg} + return {} + + def _validate_matrix(self, answers): + # Validate that each line has been answered + if self.constr_mandatory and len(self.labels_ids_2) != len(answers): + return {self.id: self.constr_error_msg} + return {} @api.depends('survey_id.question_and_page_ids.is_page', 'survey_id.question_and_page_ids.sequence') def _compute_question_ids(self): diff --git a/addons/survey/models/survey_user.py b/addons/survey/models/survey_user.py index 33fc0c8776a..37e822da1ba 100644 --- a/addons/survey/models/survey_user.py +++ b/addons/survey/models/survey_user.py @@ -267,240 +267,96 @@ class SurveyUserInputLine(models.Model): vals.update({'answer_score': self.env['survey.label'].browse(int(value_suggested)).answer_score}) return super(SurveyUserInputLine, self).write(vals) + def _get_save_line_values(self, answer, answer_type): + if not answer or (isinstance(answer, str) and not answer.strip()): + return {'answer_type': None, 'skipped': True} + if answer_type == 'suggestion': + return {'answer_type': answer_type, 'value_suggested': answer} + value = float(answer) if answer_type == 'number' else answer + return {'answer_type': answer_type, 'value_' + answer_type: value} + @api.model - def save_lines(self, user_input_id, question, post, answer_tag): + def save_lines(self, user_input_id, question, answer, comment=None): """ Save answers to questions, depending on question type If an answer already exists for question and user_input_id, it will be - overwritten (in order to maintain data consistency). + overwritten (or deleted for 'choice' questions) (in order to maintain data consistency). """ - try: - saver = getattr(self, 'save_line_' + question.question_type) - except AttributeError: - _logger.error(question.question_type + ": This type of question has no saving function") - return False - else: - saver(user_input_id, question, post, answer_tag) - - @api.model - def save_line_free_text(self, user_input_id, question, post, answer_tag): vals = { 'user_input_id': user_input_id, 'question_id': question.id, 'survey_id': question.survey_id.id, 'skipped': False, } - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'free_text', 'value_free_text': post[answer_tag]}) - else: - vals.update({'answer_type': None, 'skipped': True}) - old_uil = self.search([ + old_answers = self.search([ ('user_input_id', '=', user_input_id), ('survey_id', '=', question.survey_id.id), ('question_id', '=', question.id) ]) - if old_uil: - old_uil.write(vals) + + if question.question_type in ['textbox', 'free_text', 'numerical_box', 'date', 'datetime']: + self._save_line_simple_answer(vals, old_answers, question, answer) + elif question.question_type in ['simple_choice', 'multiple_choice']: + self._save_line_choice(vals, old_answers, question, answer, comment) + elif question.question_type == 'matrix': + self._save_line_matrix(vals, old_answers, answer, comment) else: - old_uil.create(vals) - return True + raise AttributeError(question.question_type + ": This type of question has no saving function") @api.model - def save_line_textbox(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'text', 'value_text': post[answer_tag]}) + def _save_line_simple_answer(self, vals, old_answers, question, answer): + answer_type = question.question_type + if question.question_type == 'textbox': + answer_type = 'text' + elif question.question_type == 'numerical_box': + answer_type = 'number' + + vals.update(self._get_save_line_values(answer, answer_type)) + if old_answers: + old_answers.write(vals) else: - vals.update({'answer_type': None, 'skipped': True}) - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - if old_uil: - old_uil.write(vals) - else: - old_uil.create(vals) - return True - - @api.model - def save_line_numerical_box(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'number', 'value_number': float(post[answer_tag])}) - else: - vals.update({'answer_type': None, 'skipped': True}) - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - if old_uil: - old_uil.write(vals) - else: - old_uil.create(vals) - return True - - @api.model - def save_line_date(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'date', 'value_date': post[answer_tag]}) - else: - vals.update({'answer_type': None, 'skipped': True}) - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - if old_uil: - old_uil.write(vals) - else: - old_uil.create(vals) - return True - - @api.model - def save_line_datetime(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'datetime', 'value_datetime': post[answer_tag]}) - else: - vals.update({'answer_type': None, 'skipped': True}) - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - if old_uil: - old_uil.write(vals) - else: - old_uil.create(vals) - return True - - @api.model - def save_line_simple_choice(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - old_uil.sudo().unlink() - - if answer_tag in post and post[answer_tag].strip(): - vals.update({'answer_type': 'suggestion', 'value_suggested': int(post[answer_tag])}) - else: - vals.update({'answer_type': None, 'skipped': True}) - - # '-1' indicates 'comment count as an answer' so do not need to record it - if post.get(answer_tag) and post.get(answer_tag) != '-1': - self.create(vals) - - comment_answer = post.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() - if comment_answer: - vals.update({'answer_type': 'text', 'value_text': comment_answer, 'skipped': False, 'value_suggested': False}) - self.create(vals) - - return True - - @api.model - def save_line_multiple_choice(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - old_uil.sudo().unlink() - - ca_dict = dict_keys_startswith(post, answer_tag + '_') - comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() - if len(ca_dict) > 0: - for key in ca_dict: - # '-1' indicates 'comment count as an answer' so do not need to record it - if key != ('%s_%s' % (answer_tag, '-1')): - val = ca_dict[key] - vals.update({'answer_type': 'suggestion', 'value_suggested': bool(val) and int(val)}) - self.create(vals) - if comment_answer: - vals.update({'answer_type': 'text', 'value_text': comment_answer, 'value_suggested': False}) - self.create(vals) - if not ca_dict and not comment_answer: - vals.update({'answer_type': None, 'skipped': True}) self.create(vals) return True @api.model - def save_line_matrix(self, user_input_id, question, post, answer_tag): - vals = { - 'user_input_id': user_input_id, - 'question_id': question.id, - 'survey_id': question.survey_id.id, - 'skipped': False - } - old_uil = self.search([ - ('user_input_id', '=', user_input_id), - ('survey_id', '=', question.survey_id.id), - ('question_id', '=', question.id) - ]) - old_uil.sudo().unlink() + def _save_line_choice(self, vals, old_answers, question, answers, comment): + if not (isinstance(answers, list)): + answers = [answers] - no_answers = True - ca_dict = dict_keys_startswith(post, answer_tag + '_') + vals_list = [] + if question.question_type == 'simple_choice': + if not (question.comment_count_as_answer and question.comments_allowed and comment): + for answer in answers: + vals.update(self._get_save_line_values(answer, 'suggestion')) + vals_list.append(vals.copy()) + elif question.question_type == 'multiple_choice': + for answer in answers: + vals.update(self._get_save_line_values(answer, 'suggestion')) + vals_list.append(vals.copy()) - comment_answer = ca_dict.pop(("%s_%s" % (answer_tag, 'comment')), '').strip() - if comment_answer: - vals.update({'answer_type': 'text', 'value_text': comment_answer}) - self.create(vals) - no_answers = False + if comment: + vals.update({'answer_type': 'text', 'value_text': comment, 'skipped': False, 'value_suggested': False}) + vals_list.append(vals.copy()) - if question.matrix_subtype == 'simple': - for row in question.labels_ids_2: - a_tag = "%s_%s" % (answer_tag, row.id) - if a_tag in ca_dict: - no_answers = False - vals.update({'answer_type': 'suggestion', 'value_suggested': ca_dict[a_tag], 'value_suggested_row': row.id}) - self.create(vals) + old_answers.sudo().unlink() + self.create(vals_list) + + return True + + @api.model + def _save_line_matrix(self, vals, old_answers, answers, comment): + vals_list = [] + + for row_key, row_answer in answers.items(): + for answer in row_answer: + vals.update({'answer_type': 'suggestion', 'value_suggested': answer, 'value_suggested_row': row_key}) + vals_list.append(vals.copy()) + + if comment: + vals.update({'answer_type': 'text', 'value_text': comment, 'skipped': False, 'value_suggested': False}) + vals_list.append(vals.copy()) + + old_answers.sudo().unlink() + self.create(vals_list) - elif question.matrix_subtype == 'multiple': - for col in question.labels_ids: - for row in question.labels_ids_2: - a_tag = "%s_%s_%s" % (answer_tag, row.id, col.id) - if a_tag in ca_dict: - no_answers = False - vals.update({'answer_type': 'suggestion', 'value_suggested': col.id, 'value_suggested_row': row.id}) - self.create(vals) - if no_answers: - vals.update({'answer_type': None, 'skipped': True}) - self.create(vals) return True diff --git a/addons/survey/static/src/js/survey_form.js b/addons/survey/static/src/js/survey_form.js index 23ee96d71ea..a8ccd8324b0 100644 --- a/addons/survey/static/src/js/survey_form.js +++ b/addons/survey/static/src/js/survey_form.js @@ -10,7 +10,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ selector: '.o_survey_form', events: { 'change .o_survey_form_choice_item': '_onChangeChoiceItem', - 'click button[name="button_submit"]': '_onSubmit', + 'click button[type="submit"]': '_onSubmit', 'click .o_survey_header .breadcrumb-item a': '_onBreadcrumbClick', }, @@ -30,7 +30,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ this.surveyTimerWidget = new publicWidget.registry.SurveyTimerWidget(this); this.surveyTimerWidget.attachTo($timer); this.surveyTimerWidget.on('time_up', this, function (ev) { - self.$el.find('button[name="button_submit"]').click(); + self.$el.find('button[type="submit"]').click(); }); } @@ -101,9 +101,11 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ _onSubmit: function (event) { event.preventDefault(); - var $target = $(event.currentTarget); - var submitValue = $target.val(); - this._submitForm({'button_submit': submitValue}) + if ($(event.currentTarget).val() === 'previous') { + this._submitForm({'previous_id': $(event.currentTarget).data('previousId')}); + } else { + this._submitForm({}); + } }, _submitForm: function (params) { @@ -111,9 +113,6 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ var $form = this.$('form'); var formData = new FormData($form[0]); - this.$('div.o_survey_form_date').each(function () { - self._updateDateForSubmit($(this), formData); - }); this._prepareSubmitValues(formData, params); this._resetErrors(); @@ -149,64 +148,113 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ } }, - // INIT FIELDS + // PREPARE SUBMIT TOOLS // ------------------------------------------------------------------------- /* - * Convert the client side date format into server side date format before submit + * For each type of question, extract the answer from inputs or textarea (comment or answer) + * + * + * @private + * @param {Event} event */ - _updateDateForSubmit: function (dateGroup, formData) { - var input = $(dateGroup).find('input'); - var dateValue = input.val(); - var questionType = $(input).closest('.o_survey_form_date').data('questiontype'); - if (dateValue) { - var momentDate = questionType === 'datetime' ? field_utils.parse.datetime(dateValue, null, {timezone: true}) : field_utils.parse.date(dateValue); - var newDate = momentDate ? momentDate.toJSON() : ''; - formData.set(input.attr('name'), newDate); - input.val(newDate); - } - }, - - // TOOLS - // ------------------------------------------------------------------------- _prepareSubmitValues: function (formData, params) { var self = this; + // Get all context params -- TODO : Use formData instead (test if input with no name are in formData) formData.forEach(function(value, key){ - if (value !== -1) { - // Handles Comment - if (key.indexOf('_comment') !== -1){ - key = key.split('_comment')[0]; - value = {'comment': value}; - } - // Handles Matrix - Matrix answer_tag are composed like : 'questionId_rowId_colId' - // and are the only ones with this structure. - var splitKey = key.split('_'); - if (splitKey.length === 3 && splitKey[2] === value) { - params = self._prepareSubmitMatrix(params, splitKey, value); - } - // Handles the rest - else { - params = self._prepareSubmitOther(params, key, value); - } + switch (key) { + case 'csrf_token': + case 'token': + case 'page_id': + case 'question_id': + params[key] = value; + break; + } + }); + + // Get all question answers by question type + this.$('[data-question-type]').each(function () { + switch ($(this).data('questionType')) { + case 'free_text': + case 'textbox': + case 'numerical_box': + params[this.name] = this.value; + break; + case 'date': + params = self._prepareSubmitDates(params, this.name, this.value, false); + break; + case 'datetime': + params = self._prepareSubmitDates(params, this.name, this.value, true); + break; + case 'simple_choice_dropdown': + params = self._prepareSubmitChoices(params, $(this), $(this).data('name'), 'option:selected'); + break; + case 'simple_choice_radio': + case 'multiple_choice': + params = self._prepareSubmitChoices(params, $(this), $(this).data('name'), 'input:checked'); + break; + case 'matrix': + params = self._prepareSubmitAnswersMatrix(params, $(this)); + break; } }); }, + /** + * Prepare date answer before submitting form. + * Convert date value from client current timezone to UTC Date to correspond to the server format. + * return params = { 'dateQuestionId' : '2019-05-23', 'datetimeQuestionId' : '2019-05-23 14:05:12' } + */ + _prepareSubmitDates: function(params, questionId, value, isDateTime) { + var momentDate = isDateTime ? field_utils.parse.datetime(value, null, {timezone: true}) : field_utils.parse.date(value); + var formattedDate = momentDate ? momentDate.toJSON() : ''; + params[questionId] = formattedDate; + return params; + }, + + /** + * Prepare choice answer before submitting form. + * If the answer is not the 'comment selection' (=Other), calls the _prepareSubmitAnswer method to add the answer to the params + * If there is a comment linked to that question, calls the _prepareSubmitComment method to add the comment to the params + */ + _prepareSubmitChoices: function(params, $parent, questionId, selector) { + var self = this; + $parent.find(selector).each(function () { + if (this.value != '-1') { + params = self._prepareSubmitAnswer(params, questionId, this.value); + } + }); + params = self._prepareSubmitComment(params, $parent, questionId, false); + return params; + }, + + + /** + * Prepare matrix answers before submitting form. + * This method adds matrix answers one by one and add comment if any to a params key,value like : + * params = { 'matrixQuestionId' : {'rowId1': [colId1, colId2,...], 'rowId2': [colId1, colId3, ...], 'comment': comment }} + */ + _prepareSubmitAnswersMatrix: function(params, $matrixDiv) { + var self = this; + $matrixDiv.find('input:checked').each(function () { + params = self._prepareSubmitAnswerMatrix(params, $matrixDiv.data('name'), $(this).data('rowId'), this.value); + }); + params = self._prepareSubmitComment(params, $matrixDiv, $matrixDiv.data('name'), true); + return params; + }, + /** * Prepare answer before submitting form if question type is matrix. - * This method regroups answers by question and by row to make a object like : + * This method regroups answers by question and by row to make an object like : * params = { 'matrixQuestionId' : { 'rowId1' : [colId1, colId2,...], 'rowId2' : [colId1, colId3, ...] } } */ - _prepareSubmitMatrix: function(params, splitKey, value) { - var key = splitKey[0]; - var rowId = splitKey[1]; - var colId = splitKey[2]; - value = key in params ? params[key] : {}; + _prepareSubmitAnswerMatrix: function(params, questionId, rowId, colId) { + var value = questionId in params ? params[questionId] : {}; if (rowId in value) { value[rowId].push(colId); } else { value[rowId] = [colId]; } - params[key] = value; + params[questionId] = value; return params; }, @@ -216,37 +264,43 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ * Lonely answer are directly assigned to questionId. Multiple answers are regrouped in an array: * params = { 'questionId1' : lonelyAnswer, 'questionId2' : [multipleAnswer1, multipleAnswer2, ...] } */ - _prepareSubmitOther: function(params, key, value) { - if (key in params) { - if (params[key].constructor === Array) { - params[key].push(value); + _prepareSubmitAnswer: function(params, questionId, value) { + if (questionId in params) { + if (params[questionId].constructor === Array) { + params[questionId].push(value); } else { - params[key] = [params[key], value]; + params[questionId] = [params[questionId], value]; } } else { - params[key] = value; + params[questionId] = value; } return params; }, /** - * Convert date value in client current timezone (if already answered) + * Prepare comment before submitting form. + * This method extract the comment, encapsulate it in a dict and calls the _prepareSubmitAnswer methods + * with the new value. At the end, the result looks like : + * params = { 'questionId1' : {'comment': commentValue}, 'questionId2' : [multipleAnswer1, {'comment': commentValue}, ...] } */ - _formatDateValue: function ($dateGroup) { - var input = $dateGroup.find('input'); - var dateValue = input.val(); - if (dateValue !== '') { - var momentDate = field_utils.parse.date(dateValue); - if ($dateGroup.data('questiontype') === 'datetime') { - dateValue = field_utils.format.datetime(momentDate, null, {timezone: true}); - } else { - dateValue = field_utils.format.date(momentDate, null, {timezone: true}); + _prepareSubmitComment: function(params, $parent, questionId, isMatrix) { + var self = this; + $parent.find('textarea').each(function () { + if (this.value) { + var value = {'comment': this.value}; + if (isMatrix) { + params = self._prepareSubmitAnswerMatrix(params, questionId, this.name, value); + } else { + params = self._prepareSubmitAnswer(params, questionId, value); + } } - } - input.val(dateValue); - return dateValue + }); + return params }, + // INIT FIELDS TOOLS + // ------------------------------------------------------------------------- + /** * Initialize datetimepicker in correct format and with constraints */ @@ -259,7 +313,7 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ var maxDate = maxDateData ? this._formatDateTime(maxDateData) : moment().add(200, "y"); var datetimepickerFormat = time.getLangDateFormat() - if ($dateGroup.data('questiontype') === 'datetime') { + if ($dateGroup.find('input').data('questionType') === 'datetime') { datetimepickerFormat = time.getLangDatetimeFormat() } else { // Include min and max date in selectable values @@ -296,6 +350,9 @@ publicWidget.registry.SurveyFormWidget = publicWidget.Widget.extend({ return field_utils.format.datetime(moment(datetimeValue), null, {timezone: true}); }, + // ERRORS TOOLS + // ------------------------------------------------------------------------- + _scrollToError: function ($target) { var scrollLocation = $target.offset().top; var navbarHeight = $('.o_main_navbar').height(); diff --git a/addons/survey/static/tests/tours/certification_failure.js b/addons/survey/static/tests/tours/certification_failure.js index e26ac61b17c..5b5fb1c9ea0 100644 --- a/addons/survey/static/tests/tours/certification_failure.js +++ b/addons/survey/static/tests/tours/certification_failure.js @@ -62,7 +62,7 @@ var failSteps = [{ // Page-1 } }, { content: "Finish Survey", - trigger: 'button[value="finish"]', + trigger: 'button[type="submit"]', }]; var retrySteps = [{ diff --git a/addons/survey/static/tests/tours/certification_success.js b/addons/survey/static/tests/tours/certification_success.js index afefe3648eb..03d3a22a9d7 100644 --- a/addons/survey/static/tests/tours/certification_success.js +++ b/addons/survey/static/tests/tours/certification_success.js @@ -62,7 +62,7 @@ tour.register('test_certification_success', { } }, { content: "Finish Survey", - trigger: 'button[value="finish"]', + trigger: 'button[type="submit"]', }, { content: "Thank you", trigger: 'h1:contains("Thank you!")', diff --git a/addons/survey/static/tests/tours/survey.js b/addons/survey/static/tests/tours/survey.js index 422ee75a68e..e4540fba1ab 100644 --- a/addons/survey/static/tests/tours/survey.js +++ b/addons/survey/static/tests/tours/survey.js @@ -31,7 +31,7 @@ tour.register('test_survey', { trigger: 'div.js_question-wrapper:contains("How many times did you order products on our website") input', run: 'text 12', }, { - content: 'Click on Next Page', + content: 'Submit and go to Next Page', trigger: 'button[value="next"]', }, // Page-2 @@ -61,7 +61,7 @@ tour.register('test_survey', { trigger: 'div.js_question-wrapper:contains("Do you have any other comments, questions, or concerns") textarea', run: 'text This is great. Really.', }, { - content: 'Click Submit', + content: 'Click Submit and finish the survey', trigger: 'button[value="finish"]', }, // Final page diff --git a/addons/survey/static/tests/tours/survey_prefill.js b/addons/survey/static/tests/tours/survey_prefill.js index 845a3bdf53a..d93f3d96210 100644 --- a/addons/survey/static/tests/tours/survey_prefill.js +++ b/addons/survey/static/tests/tours/survey_prefill.js @@ -55,23 +55,16 @@ tour.register('test_survey_prefill', { trigger: 'div.js_question-wrapper:contains("Do you have any other comments, questions, or concerns") textarea', run: 'text Is the prefill working?', }, { - content: 'Click on Previous Page', - trigger: 'button[value="previous"]', + // Go back to previous page + content: 'Click on the previous page name in the breadcrumb', + trigger: 'ol.breadcrumb a:first', }, { trigger: 'div.js_question-wrapper:contains("How many times did you order products on our website ?") input', run: function () { - // wait for prefill - // booo ugly (but will be removed when ajax prefill is removed) - var maxAttempts = 0; - var checkPrefillLoaded = setInterval(function () { - if ($('div.js_question-wrapper:contains("How many times did you order products on our website ?") input').val() === '42.0') { - $('.o_survey_title').addClass('prefilled'); - clearInterval(checkPrefillLoaded); - } else if (maxAttempts >= 50) { - clearInterval(checkPrefillLoaded); - } - maxAttempts++; - }, 100); // check every 100ms + var $inputQ3 = $('div.js_question-wrapper:contains("How many times did you order products on our website ?") input'); + if ($inputQ3.val() === '42.0') { + $('.o_survey_title').addClass('prefilled'); + } } }, { trigger: '.o_survey_title.prefilled', @@ -107,18 +100,10 @@ tour.register('test_survey_prefill', { }, { trigger: 'div.js_question-wrapper:contains("Do you have any other comments, questions, or concerns") textarea', run: function () { - // wait for prefill - // booo ugly (but will be removed when ajax prefill is removed) - var maxAttempts = 0; - var checkPrefillLoaded = setInterval(function () { - if ($('div.js_question-wrapper:contains("Do you have any other comments, questions, or concerns") textarea').val() === "Is the prefill working?") { - $('.o_survey_title').addClass('prefilled2'); - clearInterval(checkPrefillLoaded); - } else if (maxAttempts >= 50) { - clearInterval(checkPrefillLoaded); - } - maxAttempts++; - }, 100); // check every 100ms + var $inputQ3 = $('div.js_question-wrapper:contains("Do you have any other comments, questions, or concerns") textarea'); + if ($inputQ3.val() === "Is the prefill working?") { + $('.o_survey_title').addClass('prefilled2'); + } } }, { trigger: '.o_survey_title.prefilled2', diff --git a/addons/survey/tests/common.py b/addons/survey/tests/common.py index 25f600ef5a1..f279ef9e445 100644 --- a/addons/survey/tests/common.py +++ b/addons/survey/tests/common.py @@ -137,7 +137,10 @@ class TestSurveyCommon(common.SavepointCase): else: [value] = user_input['value'] answer_fname = self._type_match[question.question_type][1] - self.assertEqual(getattr(answer_lines, answer_fname), value) + if question.question_type == 'numerical_box': + self.assertEqual(getattr(answer_lines, answer_fname), float(value)) + else: + self.assertEqual(getattr(answer_lines, answer_fname), value) def assertResponse(self, response, status_code, text_bits=None): self.assertEqual(response.status_code, status_code) @@ -222,3 +225,20 @@ class TestSurveyCommon(common.SavepointCase): def _find_csrf_token(self, text): csrf_token_re = re.compile("(input.+csrf_token.+value=\")([_a-zA-Z0-9]{51})", re.MULTILINE) return csrf_token_re.search(text).groups()[1] + + def _prepare_post_data(self, question, answers, post_data): + values = answers if isinstance(answers, list) else [answers] + if question.question_type == 'multiple_choice': + for value in values: + value = str(value) + if question.id in post_data: + if isinstance(post_data[question.id], list): + post_data[question.id].append(value) + else: + post_data[question.id] = [post_data[question.id], value] + else: + post_data[question.id] = value + else: + [values] = values + post_data[question.id] = str(values) + return post_data diff --git a/addons/survey/tests/test_certification_flow.py b/addons/survey/tests/test_certification_flow.py index d1c6f39405f..f9a9010b68e 100644 --- a/addons/survey/tests/test_certification_flow.py +++ b/addons/survey/tests/test_certification_flow.py @@ -24,14 +24,7 @@ class TestCertificationFlow(common.TestSurveyCommon, HttpCase): def _format_submission_data(self, question, answer, additional_post_data): post_data = {} post_data['question_id'] = question.id - if question.question_type == 'multiple_choice': - values = answer - for value in values: - key = "%s_%s_%s" % (question.survey_id.id, question.id, value) - post_data[key] = value - else: - key = "%s_%s" % (question.survey_id.id, question.id) - post_data[key] = answer + post_data.update(self._prepare_post_data(question, answer, post_data)) post_data.update(**additional_post_data) return post_data diff --git a/addons/survey/tests/test_survey.py b/addons/survey/tests/test_survey.py index 67e44eb33de..6c64d47f763 100644 --- a/addons/survey/tests/test_survey.py +++ b/addons/survey/tests/test_survey.py @@ -29,10 +29,9 @@ class TestSurveyInternals(common.TestSurveyCommon): kwargs['labels_2'] = [{'value': 'Row0'}, {'value': 'Row1'}] question = self._add_question(self.page_0, 'Q0', question_type, **kwargs) - answer_tag = '%s_%s' % (self.survey.id, question.id) self.assertDictEqual( - question.validate_question({answer_tag: ''}, answer_tag), - {answer_tag: 'TestError'} + question.validate_question(''), + {question.id: 'TestError'} ) @users('survey_manager') @@ -40,25 +39,24 @@ class TestSurveyInternals(common.TestSurveyCommon): question = self._add_question( self.page_0, 'Q0', 'date', validation_required=True, validation_min_date='2015-03-20', validation_max_date='2015-03-25', validation_error_msg='ValidationError') - answer_tag = '%s_%s' % (self.survey.id, question.id) self.assertEqual( - question.validate_question({answer_tag: 'Is Alfred an answer ?'}, answer_tag), - {answer_tag: _('This is not a date')} + question.validate_question('Is Alfred an answer ?'), + {question.id: _('This is not a date')} ) self.assertEqual( - question.validate_question({answer_tag: '2015-03-19'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('2015-03-19'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: '2015-03-26'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('2015-03-26'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: '2015-03-25'}, answer_tag), + question.validate_question('2015-03-25'), {} ) @@ -67,40 +65,38 @@ class TestSurveyInternals(common.TestSurveyCommon): question = self._add_question( self.page_0, 'Q0', 'numerical_box', validation_required=True, validation_min_float_value=2.2, validation_max_float_value=3.3, validation_error_msg='ValidationError') - answer_tag = '%s_%s' % (self.survey.id, question.id) self.assertEqual( - question.validate_question({answer_tag: 'Is Alfred an answer ?'}, answer_tag), - {answer_tag: _('This is not a number')} + question.validate_question('Is Alfred an answer ?'), + {question.id: _('This is not a number')} ) self.assertEqual( - question.validate_question({answer_tag: '2.0'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('2.0'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: '4.0'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('4.0'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: '2.9'}, answer_tag), + question.validate_question('2.9'), {} ) @users('survey_manager') def test_answer_validation_textbox_email(self): question = self._add_question(self.page_0, 'Q0', 'textbox', validation_email=True) - answer_tag = '%s_%s' % (self.survey.id, question.id) self.assertEqual( - question.validate_question({answer_tag: 'not an email'}, answer_tag), - {answer_tag: _('This answer must be an email address')} + question.validate_question('not an email'), + {question.id: _('This answer must be an email address')} ) self.assertEqual( - question.validate_question({answer_tag: 'email@example.com'}, answer_tag), + question.validate_question('email@example.com'), {} ) @@ -109,20 +105,19 @@ class TestSurveyInternals(common.TestSurveyCommon): question = self._add_question( self.page_0, 'Q0', 'textbox', validation_required=True, validation_length_min=2, validation_length_max=8, validation_error_msg='ValidationError') - answer_tag = '%s_%s' % (self.survey.id, question.id) self.assertEqual( - question.validate_question({answer_tag: 'l'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('l'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: 'waytoomuchlonganswer'}, answer_tag), - {answer_tag: 'ValidationError'} + question.validate_question('waytoomuchlonganswer'), + {question.id: 'ValidationError'} ) self.assertEqual( - question.validate_question({answer_tag: 'valid'}, answer_tag), + question.validate_question('valid'), {} ) diff --git a/addons/survey/tests/test_survey_flow.py b/addons/survey/tests/test_survey_flow.py index 5712682df3f..472eaff55fd 100644 --- a/addons/survey/tests/test_survey_flow.py +++ b/addons/survey/tests/test_survey_flow.py @@ -13,15 +13,7 @@ class TestSurveyFlow(common.TestSurveyCommon, HttpCase): post_data['page_id'] = page.id for question_id, answer_vals in answer_data.items(): question = page.question_ids.filtered(lambda q: q.id == question_id) - if question.question_type == 'multiple_choice': - values = answer_vals['value'] - for value in values: - key = "%s_%s_%s" % (page.survey_id.id, question.id, value) - post_data[key] = value - else: - [value] = answer_vals['value'] - key = "%s_%s" % (page.survey_id.id, question.id) - post_data[key] = value + post_data.update(self._prepare_post_data(question, answer_vals['value'], post_data)) post_data.update(**additional_post_data) return post_data @@ -96,7 +88,7 @@ class TestSurveyFlow(common.TestSurveyCommon, HttpCase): # Customer submit first page answers answer_data = { page0_q0.id: {'value': ['Alfred Poilvache']}, - page0_q1.id: {'value': [44.0]}, + page0_q1.id: {'value': ['44.0']}, } post_data = self._format_submission_data(page_0, answer_data, {'csrf_token': csrf_token, 'token': answer_token, 'button_submit': 'next'}) r = self._access_submit(survey, answer_token, post_data) diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 4604b3ddd12..494c667d23c 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -269,7 +269,7 @@
- +
@@ -285,8 +285,9 @@
- - +
@@ -297,8 +298,9 @@
- - +
@@ -319,9 +321,8 @@