[IMP-REF] survey : refactor questions naming, validation and save flow

This commit simplifies the answer_tag and question name
by removing the survey_id (uses now only question id) for the most simple cases

Factorise the save and validate survey answers to avoid duplicate code

Review posted submit data :
- process all questions by question type (instead of using form data
(key,value) that needed key parsing and was un-typed)
- regroup answers by questions and adapt all validation and save flow
- remove post data in save and validate question methods
  and uses directly the answer(s)
- remove useless input names

The tests have been adapted consequently

Task ID : 1930132
PR #32419
This commit is contained in:
David Beguin
2019-11-19 12:52:22 +00:00
parent b1e32976a6
commit 3bb4ac8dc6
14 changed files with 449 additions and 578 deletions
+61 -16
View File
@@ -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
@@ -68,11 +68,6 @@
<field name="sequence">4</field>
<field name="value">Once a year</field>
</record>
<record model="survey.label" id="survey_feedback_p1_q3_sug5">
<field name="question_id" ref="survey_feedback_p1_q3"/>
<field name="sequence">5</field>
<field name="value">Other (answer in comment)</field>
</record>
<record model="survey.question" id="survey_feedback_p1_q4">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">5</field>
@@ -140,11 +135,6 @@
<field name="sequence">8</field>
<field name="value">Poor quality</field>
</record>
<record model="survey.label" id="survey_feedback_p2_q1_sug9">
<field name="question_id" ref="survey_feedback_p2_q1"/>
<field name="sequence">9</field>
<field name="value">Other</field>
</record>
<record model="survey.question" id="survey_feedback_p2_q2">
<field name="survey_id" ref="survey_feedback" />
<field name="sequence">8</field>
+80 -151
View File
@@ -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
# <something>@<anything>.<extension>
# will be accepted
if answer and self.validation_email:
# all the strings of the form "<something>@<anything>.<extension>" 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):
+65 -209
View File
@@ -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
+124 -67
View File
@@ -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();
@@ -62,7 +62,7 @@ var failSteps = [{ // Page-1
}
}, {
content: "Finish Survey",
trigger: 'button[value="finish"]',
trigger: 'button[type="submit"]',
}];
var retrySteps = [{
@@ -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!")',
+2 -2
View File
@@ -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
@@ -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',
+21 -1
View File
@@ -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
@@ -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
+24 -29
View File
@@ -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'),
{}
)
+2 -10
View File
@@ -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)
+56 -47
View File
@@ -269,7 +269,7 @@
</t>
<div class="text-center mt16 mb256">
<button type="submit" class="btn btn-primary" name="button_submit" value="finish">Submit Survey</button>
<button type="submit" class="btn btn-primary">Submit Survey</button>
</div>
</t>
@@ -285,8 +285,9 @@
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and page != survey.page_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button t-if="not last" type="submit" class="btn btn-primary" name="button_submit" value="next">Next page</button>
<button t-if="last" type="submit" class="btn btn-primary" name="button_submit" value="finish">Submit Survey</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
@@ -297,8 +298,9 @@
<div class="text-center mt16 mb256">
<button t-if="survey.users_can_go_back and question != answer.question_ids[0]" type="submit" class="btn btn-secondary"
name="button_submit" value="previous" t-att-data-previous-page-id="previous_page_id">Previous page</button>
<button t-if="not last" type="submit" class="btn btn-primary" name="button_submit" value="next">Next question</button>
<button t-if="last" type="submit" class="btn btn-primary" name="button_submit" value="finish">Submit Survey</button>
<button type="submit" t-att-value="'next' if not last else 'finish'" class="btn btn-primary">
<t t-esc="'Next Page' if not last else 'Submit Survey'"/>
</button>
</div>
</t>
</form>
@@ -319,9 +321,8 @@
</template>
<template id="question" name="Question">
<t t-set="prefix" t-value="'%s_%s' % (survey.id, question.id)" />
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<div class="js_question-wrapper" t-att-id="prefix">
<div class="js_question-wrapper" t-att-id="question.id">
<h4>
<span t-field='question.question' />
<span t-if="question.constr_mandatory" class="text-danger">*</span>
@@ -341,23 +342,28 @@
<!-- Question widgets -->
<template id="free_text" name="Free text box">
<textarea class="form-control" rows="3" t-att-name="prefix"><t t-if="answer_lines" t-esc="answer_lines[0].value_free_text or None"/></textarea>
<textarea class="form-control o_survey_question_free_text" rows="3" t-att-name="question.id"
t-att-data-question-type="question.question_type"><t t-if="answer_lines" t-esc="answer_lines[0].value_free_text or None"/></textarea>
</template>
<template id="textbox" name="Text box">
<input type="text" class="form-control" t-att-name="prefix" t-att-value="answer_lines[0].value_text if answer_lines else None"/>
<input type="text" class="form-control o_survey_question_text_box" t-att-name="question.id" t-att-value="answer_lines[0].value_text if answer_lines else None"
t-att-data-question-type="question.question_type"/>
</template>
<template id="numerical_box" name="Numerical box">
<input type="number" step="any" class="form-control" t-att-name="prefix" t-att-value="answer_lines[0].value_number if answer_lines else None"/>
<input type="number" step="any" class="form-control o_survey_question_numerical_box" t-att-name="question.id" t-att-value="answer_lines[0].value_number if answer_lines else None"
t-att-data-question-type="question.question_type"/>
</template>
<template id="date" name="Date box">
<div class="input-group o_survey_form_date" t-attf-id="datetimepicker_#{question.id}" data-target-input="nearest"
t-att-data-mindate="question.validation_min_date"
t-att-data-maxdate="question.validation_max_date">
<input type="text" class="form-control datetimepicker-input" t-attf-data-target="#datetimepicker_#{question.id}" t-att-name="prefix"
t-att-value="format_date(answer_lines[0].value_date) if answer_lines else None"/>
<input type="text" class="form-control datetimepicker-input o_survey_question_date"
t-attf-data-target="#datetimepicker_#{question.id}" t-att-name="question.id"
t-att-value="format_date(answer_lines[0].value_date) if answer_lines else None"
t-att-data-question-type="question.question_type"/>
<div t-if="not survey_form_readonly" class="input-group-append" t-attf-data-target="#datetimepicker_#{question.id}" data-toggle="datetimepicker">
<div class="input-group-text"><i class="fa fa-calendar"></i></div>
</div>
@@ -367,10 +373,11 @@
<template id="datetime" name="DateTime box">
<div class="input-group o_survey_form_date" t-attf-id="datetimepicker_#{question.id}" data-target-input="nearest"
t-att-data-mindate="question.validation_min_datetime"
t-att-data-maxdate="question.validation_max_datetime"
t-att-data-questiontype="question.question_type">
<input type="text" class="form-control datetimepicker-input" t-attf-data-target="#datetimepicker_#{question.id}" t-att-name="prefix"
t-att-value="format_datetime(answer_lines[0].value_datetime) if answer_lines else None"/>
t-att-data-maxdate="question.validation_max_datetime">
<input type="text" class="form-control datetimepicker-input o_survey_question_datetime"
t-attf-data-target="#datetimepicker_#{question.id}" t-att-name="question.id"
t-att-value="format_datetime(answer_lines[0].value_datetime) if answer_lines else None"
t-att-data-question-type="question.question_type"/>
<div t-if="not survey_form_readonly" class="input-group-append" t-attf-data-target="#datetimepicker_#{question.id}" data-toggle="datetimepicker">
<div class="input-group-text"><i class="fa fa-calendar"></i></div>
</div>
@@ -380,9 +387,11 @@
<template id="simple_choice" name="Simple choice">
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.value_suggested)"/>
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_text)"/>
<div t-if="question.display_mode == 'dropdown'" class="o_survey_form_choice row">
<div t-if="question.display_mode == 'dropdown'" class="o_survey_form_choice o_survey_question_simple_choice_dropdown row"
t-att-data-name="question.id"
data-question-type="simple_choice_dropdown">
<div class="col-lg-12">
<select class="form-control o_survey_form_choice_item" t-att-name="prefix">
<select class="form-control o_survey_form_choice_item">
<option disabled="1" selected="1" value=""
t-att-selected="not answer_line and (not comment_line or not question.comment_count_as_answer) and 'selected' or None">Choose...</option>
<t t-foreach='question.labels_ids' t-as='label'>
@@ -399,73 +408,74 @@
</div>
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="col-lg-6">
<textarea type="text" class="form-control"
t-attf-class="form-control #{'' if comment_line else 'd-none'}"
t-att-name="'%s_%s' % (prefix, 'comment')" data-oe-survey-othert="1"
t-att-value="comment_line.value_text if comment_line else ''"/>
t-attf-class="form-control #{'' if comment_line else 'd-none'}"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
<div t-if='question.comments_allowed and not question.comment_count_as_answer' class="col-lg-12 mt16">
<span t-field="question.comments_message"/>
<textarea type="text" class="form-control o_survey_comment" t-att-name="'%s_%s' % (prefix, 'comment')"
t-att-value="comment_line.value_text if comment_line else ''"/>
<textarea type="text" class="form-control o_survey_comment"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
</div>
<div t-if="question.display_mode == 'columns'" class="row o_survey_form_choice">
<div t-if="question.display_mode == 'columns'" class="row o_survey_form_choice o_survey_question_simple_choice_radio"
t-att-data-name="question.id"
data-question-type="simple_choice_radio">
<div t-foreach='question.labels_ids' t-as='label' t-attf-class="col-lg-#{question.column_nb}">
<label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None">
<input type="radio" t-att-name="prefix" t-att-value='label.id' class="o_survey_form_choice_item"
<input type="radio" t-att-value='label.id' class="o_survey_form_choice_item"
t-att-checked="answer_line and answer_line.value_suggested.id == label.id and 'checked' or None"/>
<span t-field='label.value'/>
</label>
</div>
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="js_comments col-lg-12" >
<label>
<input type="radio" class="o_survey_form_choice_item o_survey_js_form_other_comment" t-att-name="prefix" value="-1"
<input type="radio" class="o_survey_form_choice_item o_survey_js_form_other_comment" value="-1"
t-att-checked="comment_line and 'checked' or None"/>
<span t-field="question.comments_message" />
</label>
<textarea type="text" class="form-control" rows="1"
t-attf-disabled="#{'' if comment_line else 'disabled'}"
t-att-name="'%s_%s' % (prefix, 'comment')"/>
<textarea type="text" class="form-control"
t-attf-disabled="#{'' if comment_line else 'disabled'}"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
<div t-if='question.comments_allowed and not question.comment_count_as_answer' class="col-lg-12">
<span t-field="question.comments_message"/>
<textarea type="text" class="form-control o_survey_comment" rows="1" t-att-name="'%s_%s' % (prefix, 'comment')" data-oe-survey-othert="1"/>
<textarea type="text" class="form-control o_survey_comment"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
</div>
</template>
<template id="multiple_choice" name="Multiple choice">
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_text)"/>
<div class="row o_survey_form_choice">
<div class="row o_survey_form_choice o_survey_question_multiple_choice"
t-att-data-name="question.id"
t-att-data-question-type="question.question_type">
<div t-foreach='question.labels_ids' t-as='label' t-attf-class="col-lg-#{question.column_nb}">
<t t-set="answer_line" t-value="answer_lines.filtered(lambda line: line.value_suggested == label)"/>
<label t-att-class="' bg-success ' if quizz_correction and label.answer_score > 0.0 else None">
<input type="checkbox" t-att-name="'%s_%s' % (prefix, label.id)" t-att-value='label.id' class="o_survey_form_choice_item"
<input type="checkbox" t-att-value='label.id' class="o_survey_form_choice_item"
t-att-checked="'checked' if answer_line else None"/>
<span t-field='label.value'/>
</label>
</div>
<div t-if='question.comments_allowed and question.comment_count_as_answer' class="js_ck_comments col-lg-12" >
<label>
<input type="checkbox" t-att-name="'%s_%s' % (prefix, -1)" class="o_survey_form_choice_item o_survey_js_form_other_comment" value="-1"
t-att-checked="'checked' if comment_line else None"/>
<input type="checkbox" class="o_survey_form_choice_item o_survey_js_form_other_comment" value="-1"
t-att-checked="comment_line and 'checked' or None"/>
<span t-field="question.comments_message" />
</label>
<textarea type="text" class="form-control"
t-attf-disabled="#{'' if comment_line else 'disabled'}"
t-att-name="'%s_%s' % (prefix, 'comment')"
t-att-value="comment_line.value_text if comment_line else None"/>
t-attf-disabled="#{'' if comment_line else 'disabled'}"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
<div t-if='question.comments_allowed and not question.comment_count_as_answer' class="col-lg-12">
<span t-field="question.comments_message"/>
<textarea type="text" class="form-control" t-att-name="'%s_%s' % (prefix, 'comment')" data-oe-survey-othert="1"
t-att-value="comment_line.value_text if comment_line else None"/>
<textarea type="text" class="form-control o_survey_comment"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
</div>
</template>
<template id="matrix" name="Matrix">
<table class="table table-hover">
<t t-set="comment_line" t-value="answer_lines.filtered(lambda line: line.value_text)"/>
<table class="table table-hover o_survey_question_matrix"
t-att-data-name="question.id"
t-att-data-question-type="question.question_type">
<thead>
<tr>
<th> </th>
@@ -477,17 +487,17 @@
<th><span t-field="row_label.value" /></th>
<td t-foreach="question.labels_ids" t-as="col_label">
<t t-set="answer" t-value="answer_lines.filtered(lambda line: line.value_suggested == col_label and line.value_suggested_row == row_label)"/>
<input t-if="question.matrix_subtype == 'simple'" type="radio" t-att-name="'%s_%s' % (prefix, row_label.id)" t-att-value='col_label.id'
t-att-checked="'checked' if answer else None"/>
<input t-if="question.matrix_subtype == 'multiple'" type="checkbox" t-att-name="'%s_%s_%s' % (prefix, row_label.id, col_label.id)" t-att-value='col_label.id'
t-att-checked="'checked' if answer else None"/>
<input t-att-type="'checkbox' if question.matrix_subtype == 'multiple' else 'radio'"
t-att-name="'%s_%s' % (question_id, row_label.id)" t-att-value='col_label.id'
t-att-checked="'checked' if answer else None"
t-att-data-row-id="row_label.id"/>
</td>
</tr>
</tbody>
</table>
<div t-if='question.comments_allowed'>
<span t-field="question.comments_message"/>
<textarea class="form-control" rows="1" t-att-name="'%s_%s' % (prefix, 'comment')" />
<textarea type="text" class="form-control" t-att-name="'%s_%s' % (question.id, 'comment')"><t t-esc="comment_line.value_text if comment_line else ''"/></textarea>
</div>
</template>
@@ -512,9 +522,8 @@
<div t-if="question.description" t-field='question.description' class="oe_no_empty"/>
</div>
<t t-if="not question.is_page and not answer or (question in answer.question_ids)" >
<t t-set="prefix" t-value="'%s_%s' % (survey.id, question.id)" />
<t t-set="answer_lines" t-value="answer.user_input_line_ids.filtered(lambda line: line.question_id == question)"/>
<div class="js_question-wrapper" t-att-id="prefix">
<div class="js_question-wrapper" t-att-id="question.id">
<h2>
<span t-field='question.question' />
<span t-if="question.constr_mandatory" class="text-danger">*</span>